1 # This file is the equivalent to commands.coffee, but for frame scripts,
2 # allowing interaction with web page content. Most “commands” here have the
3 # same name as the command in commands.coffee that calls it. There are also a
4 # few more generalized “commands” used in more than one place.
6 markableElements = require('./markable-elements')
7 prefs = require('./prefs')
8 SelectionManager = require('./selection')
9 translate = require('./translate')
10 utils = require('./utils')
11 viewportUtils = require('./viewport')
13 {FORWARD, BACKWARD} = SelectionManager
14 {isProperLink, isTextInputElement, isTypingElement, isContentEditable} = utils
16 # <http://www.w3.org/html/wg/drafts/html/master/dom.html#wai-aria>
17 CLICKABLE_ARIA_ROLES = [
18 'link', 'button', 'tab'
19 'checkbox', 'radio', 'combobox', 'option', 'slider', 'textbox'
20 'menuitem', 'menuitemcheckbox', 'menuitemradio'
23 createComplementarySelectors = (selectors) ->
26 selectors.map((selector) -> ":not(#{selector})").join('')
29 FOLLOW_DEFAULT_SELECTORS = createComplementarySelectors([
30 'a', 'button', 'input', 'textarea', 'select', 'label'
31 '[role]', '[contenteditable]'
34 FOLLOW_CONTEXT_TAGS = [
35 'a', 'button', 'input', 'textarea', 'select'
36 'img', 'audio', 'video', 'canvas', 'embed', 'object'
39 FOLLOW_CONTEXT_SELECTORS = createComplementarySelectors(FOLLOW_CONTEXT_TAGS)
41 FOLLOW_SELECTABLE_SELECTORS =
42 createComplementarySelectors(['div', 'span']).reverse()
46 commands.go_up_path = ({vim, count = 1}) ->
47 {pathname} = vim.content.location
48 newPathname = pathname.replace(/// (?: [^/]+(/|$) ){1,#{count}} $ ///, '')
49 if newPathname == pathname
50 vim.notify(translate('notification.go_up_path.limit'))
52 vim.content.location.pathname = newPathname
54 commands.go_to_root = ({vim}) ->
55 # `.origin` is `'null'` (as a string) on `about:` pages.
56 if "#{vim.content.location.origin}/" in [vim.content.location.href, 'null/']
57 vim.notify(translate('notification.go_up_path.limit'))
59 vim.content.location.href = vim.content.location.origin
61 commands.scroll = (args) ->
63 return unless activeElement = utils.getActiveElement(vim.content)
65 # If the active element, or one of its parents, is scrollable, use that
67 scrollElement = activeElement
68 until vim.state.scrollableElements.has(scrollElement) or
69 not scrollElement.parentNode
70 scrollElement = scrollElement.parentNode
73 # If no element is focused on the page, the active element is the topmost
74 # `<body>`, and blurring it is a no-op. If it is scrollable, it means that
75 # you can’t blur it in order to scroll `<html>`. Therefore it may only be
76 # scrolled if it has been explicitly focused.
77 if vim.state.scrollableElements.has(scrollElement) and
78 (scrollElement != vim.content.document.body or
79 vim.state.explicitBodyFocus)
82 vim.state.scrollableElements.filterSuitableDefault()
83 viewportUtils.scroll(element, args)
85 commands.mark_scroll_position = (args) ->
86 {vim, keyStr, notify = true, addToJumpList = false} = args
88 vim.state.marks[keyStr] = vim.state.scrollableElements.getPageScrollPosition()
94 vim.notify(translate('notification.mark_scroll_position.success', keyStr))
96 commands.scroll_to_mark = (args) ->
97 {vim, extra: {keyStr, lastPositionMark}} = args
99 unless keyStr of vim.state.marks
100 vim.notify(translate('notification.scroll_to_mark.none', keyStr))
103 args.amounts = vim.state.marks[keyStr]
104 element = vim.state.scrollableElements.filterSuitableDefault()
106 commands.mark_scroll_position({
108 keyStr: lastPositionMark
112 viewportUtils.scroll(element, args)
114 commands.scroll_to_position = (args) ->
115 {vim, extra: {count, direction, lastPositionMark}} = args
117 if direction == 'previous' and vim.state.jumpListIndex >= 0 and
118 vim.state.jumpListIndex == vim.state.jumpList.length - 1
121 {jumpList, jumpListIndex} = vim.state
122 maxIndex = jumpList.length - 1
124 if (direction == 'previous' and jumpListIndex <= 0) or
125 (direction == 'next' and jumpListIndex >= maxIndex)
126 vim.notify(translate("notification.scroll_to_#{direction}_position.limit"))
129 index = jumpListIndex + count * (if direction == 'previous' then -1 else +1)
130 index = Math.max(index, 0)
131 index = Math.min(index, maxIndex)
133 args.amounts = jumpList[index]
134 element = vim.state.scrollableElements.filterSuitableDefault()
136 commands.mark_scroll_position({vim, keyStr: lastPositionMark, notify: false})
137 viewportUtils.scroll(element, args)
138 vim.state.jumpListIndex = index
140 helper_follow = (options, matcher, {vim, pass}) ->
141 {id, combine = true, selectors = FOLLOW_DEFAULT_SELECTORS} = options
142 if utils.isXULDocument(vim.content.document)
146 pass = if selectors.length == 2 then 'first' else 'single'
148 vim.state.markerElements = [] if pass in ['single', 'first']
151 filter = (element, getElementShape) ->
152 type = matcher({vim, element, getElementShape})
154 type = vim.hintMatcher(id, element, type)
155 if pass == 'complementary'
156 type = if type then null else 'complementary'
159 shape = getElementShape(element)
161 unless shape.nonCoveredPoint then switch
162 # CodeMirror editor uses a tiny hidden textarea positioned at the caret.
163 # Targeting those are the only reliable way of focusing CodeMirror
164 # editors, and doing so without moving the caret.
165 when id == 'normal' and element.localName == 'textarea' and
166 element.ownerGlobal == vim.content
167 rect = element.getBoundingClientRect()
168 # Use `.clientWidth` instead of `rect.width` because the latter includes
169 # the width of the borders of the textarea, which are unreliable.
170 if element.clientWidth <= 1 and rect.height > 0
174 y: rect.top + rect.height / 2
175 offset: {left: 0, top: 0}
177 area: rect.width * rect.height
180 # Putting a large `<input type="file">` inside a smaller wrapper element
181 # with `overflow: hidden;` seems to be a common pattern, used both on
182 # addons.mozilla.org and <https://blueimp.github.io/jQuery-File-Upload/>.
183 when id in ['normal', 'focus'] and element.localName == 'input' and
184 element.type == 'file' and element.parentElement?
185 parentShape = getElementShape(element.parentElement)
186 shape = parentShape if parentShape.area <= shape.area
188 if not shape.nonCoveredPoint and pass == 'complementary'
189 shape = getElementShape(element, -1)
191 return unless shape.nonCoveredPoint
193 text = utils.getText(element)
195 originalRect = element.getBoundingClientRect()
196 length = vim.state.markerElements.push({element, originalRect})
199 combinedArea: shape.area
200 elementIndex: length - 1
204 if wrapper.type == 'link'
208 # Combine links with the same href.
209 if combine and wrapper.type == 'link' and
210 # If the element has an 'onclick' attribute we cannot be sure that all
211 # links with this href actually do the same thing. On some pages, such
212 # as startpage.com, actual proper links have the 'onclick' attribute,
213 # so we can’t exclude such links in `utils.isProperLink`.
214 not element.hasAttribute?('onclick') and
215 # GitHub’s diff expansion buttons are links with both `href` and
216 # `data-url`. They are JavaScript-powered using the latter attribute.
217 not element.hasAttribute?('data-url')
220 wrapper.parentIndex = parent.elementIndex
221 parent.combinedArea += wrapper.shape.area
222 parent.numChildren += 1
224 hrefs[href] = wrapper
229 if pass == 'complementary'
232 selectors[if pass == 'second' then 1 else 0]
234 wrappers: markableElements.find(vim.content, filter, selector)
235 viewport: viewportUtils.getWindowViewport(vim.content)
239 commands.follow = helper_follow.bind(
240 null, {id: 'normal'},
241 ({vim, element, getElementShape}) ->
242 document = element.ownerDocument
243 isXUL = utils.isXULDocument(document)
246 # Bootstrap. Match these before regular links, because especially slider
247 # “buttons” often get the same hint otherwise.
248 when element.hasAttribute?('data-toggle') or
249 element.hasAttribute?('data-dismiss') or
250 element.hasAttribute?('data-slide') or
251 element.hasAttribute?('data-slide-to')
253 when isProperLink(element)
255 when isTypingElement(element)
257 when element.localName in ['a', 'button'] or
258 element.getAttribute?('role') in CLICKABLE_ARIA_ROLES or
259 # <http://www.w3.org/TR/wai-aria/states_and_properties>
260 element.hasAttribute?('aria-controls') or
261 element.hasAttribute?('aria-pressed') or
262 element.hasAttribute?('aria-checked') or
263 (element.hasAttribute?('aria-haspopup') and
264 element.getAttribute?('role') != 'menu')
266 when utils.isFocusable(element) and
267 # Google Drive Documents. The hint for this element would cover the
268 # real hint that allows you to focus the document to start typing.
269 element.id != 'docs-editor'
271 when element != vim.state.scrollableElements.largest and
272 vim.state.scrollableElements.has(element)
274 when element.hasAttribute?('onclick') or
275 element.hasAttribute?('onmousedown') or
276 element.hasAttribute?('onmouseup') or
277 element.hasAttribute?('oncommand') or
279 element.classList?.contains('js-new-tweets-bar') or
280 element.hasAttribute?('data-permalink-path') or
282 element.hasAttribute?('data-app-action') or
283 element.hasAttribute?('data-uri') or
284 element.hasAttribute?('data-page-action') or
285 # Google Drive Document.
286 element.classList?.contains('kix-appview-editor')
288 # Facebook comment fields.
289 when element.parentElement?.classList?.contains('UFIInputContainer')
290 type = 'clickable-special'
291 # Putting markers on `<label>` elements is generally redundant, because
292 # its `<input>` gets one. However, some sites hide the actual `<input>`
293 # but keeps the `<label>` to click, either for styling purposes or to keep
294 # the `<input>` hidden until it is used. In those cases we should add a
295 # marker for the `<label>`. Trying to access `.control` on an element in
296 # `about:config` throws an error, so exclude XUL pages.
297 when not isXUL and element.localName == 'label' and element.control and
298 not getElementShape(element.control).nonCoveredPoint
300 # Last resort checks for elements that might be clickable because of
303 # It is common to listen for clicks on `<html>` or `<body>`. Don’t
304 # waste time on them.
305 element not in [document.documentElement, document.body]) and
306 (utils.includes(element.className, 'button') or
307 utils.includes(element.getAttribute?('aria-label'), 'close') or
308 # Do this last as it’s a potentially expensive check.
309 (utils.hasEventListeners(element, 'click') and
310 # Twitter. The hint for this element would cover the hint for
311 # showing more tweets.
312 not element.classList?.contains('js-new-items-bar-container')))
313 # Make a quick check for likely clickable descendants, to reduce the
314 # number of false positives. the element might be a “button-wrapper” or
315 # a large element with a click-tracking event listener.
316 unless element.querySelector?('a, button, input, [class*=button]')
318 # When viewing an image it should get a marker to toggle zoom. This is the
319 # most unlikely rule to match, so keep it last.
320 when document.body?.childElementCount == 1 and
321 element.localName == 'img' and
322 (element.classList?.contains('overflowing') or
323 element.classList?.contains('shrinkToFit'))
325 type = null if isXUL and element.classList?.contains('textbox-input')
329 commands.follow_in_tab = helper_follow.bind(
330 null, {id: 'tab', selectors: ['a']},
332 type = if isProperLink(element) then 'link' else null
336 commands.follow_copy = helper_follow.bind(
340 when isProperLink(element)
342 when isContentEditable(element)
344 when isTypingElement(element)
351 commands.follow_focus = helper_follow.bind(
352 null, {id: 'focus', combine: false},
355 when utils.isFocusable(element)
357 when element != vim.state.scrollableElements.largest and
358 vim.state.scrollableElements.has(element)
365 commands.follow_context = helper_follow.bind(
366 null, {id: 'context', selectors: FOLLOW_CONTEXT_SELECTORS},
369 if element.localName in FOLLOW_CONTEXT_TAGS or
370 utils.hasMarkableTextNode(element)
377 commands.follow_selectable = helper_follow.bind(
378 null, {id: 'selectable', selectors: FOLLOW_SELECTABLE_SELECTORS},
381 if utils.hasMarkableTextNode(element)
388 commands.focus_marker_element = ({vim, elementIndex, browserOffset, options}) ->
389 {element} = vim.state.markerElements[elementIndex]
390 # To be able to focus scrollable elements, `FLAG_BYKEY` _has_ to be used.
391 options.flag = 'FLAG_BYKEY' if vim.state.scrollableElements.has(element)
392 utils.focusElement(element, options)
394 vim.setHover(element, browserOffset)
396 commands.click_marker_element = (
397 {vim, elementIndex, type, browserOffset, preventTargetBlank}
399 {element} = vim.state.markerElements[elementIndex]
400 if element.target == '_blank' and preventTargetBlank
401 targetReset = element.target
403 if type == 'clickable-special' or
404 type in ['clickable', 'link'] and utils.isInShadowRoot(element)
407 isXUL = utils.isXULDocument(element.ownerDocument)
410 if element.localName == 'tab' then ['mousedown'] else 'click-xul'
411 when type == 'context'
415 utils.simulateMouseEvents(element, sequence, browserOffset)
416 utils.openDropdown(element)
417 element.target = targetReset if targetReset
419 commands.copy_marker_element = ({vim, elementIndex, property}) ->
420 {element} = vim.state.markerElements[elementIndex]
421 utils.writeToClipboard(element[property])
423 commands.element_text_select = ({vim, elementIndex, full, scroll = false}) ->
424 {element} = vim.state.markerElements[elementIndex]
425 window = element.ownerGlobal
426 selection = window.getSelection()
427 range = window.document.createRange()
429 # Try to scroll the element into view, but keep the caret visible.
431 viewport = viewportUtils.getWindowViewport(window)
432 rect = element.getBoundingClientRect()
434 when rect.bottom > viewport.bottom
436 when rect.top < viewport.top and rect.height < viewport.height
442 prefs.root.get('general.smoothScroll') and
443 prefs.root.get('general.smoothScroll.other')
445 element.scrollIntoView({
447 behavior: if smooth then 'smooth' else 'instant'
451 range.selectNodeContents(element)
453 result = viewportUtils.getFirstNonWhitespace(element)
455 [node, offset] = result
456 range.setStart(node, offset)
457 range.setEnd(node, offset)
459 range.setStartBefore(element)
460 range.setEndBefore(element)
462 utils.clearSelectionDeep(vim.content)
464 # Focus the window so that the selection does not appear greyed out. However,
465 # if a text input was previously focused in that window (frame), that will
466 # cause the text input to be re-focused, so make sure to blur the active
467 # element, so that the caret does not end up there.
469 utils.getActiveElement(window)?.blur?()
471 selection.addRange(range)
474 # Force the selected text to appear in the “selection clipboard”. Note:
475 # `selection.modify` would not make any difference here. That makes sense,
476 # since web pages that programmatically change the selection shouldn’t
477 # affect the user’s clipboard. `SelectionManager` uses an internal Firefox
478 # API with clipboard privileges.
479 selectionManager = new SelectionManager(window)
480 error = selectionManager.moveCaret('characterMove', BACKWARD)
481 selectionManager.moveCaret('characterMove', FORWARD) unless error
483 commands.follow_pattern = ({vim, type, browserOffset, options}) ->
484 {document} = vim.content
486 # If there’s a `<link rel=prev/next>` element we use that.
487 for link in document.head?.getElementsByTagName('link')
488 # Also support `rel=previous`, just like Google.
489 if type == link.rel.toLowerCase().replace(/^previous$/, 'prev')
490 vim.content.location.href = link.href
493 # Otherwise we look for a link or button on the page that seems to go to the
494 # previous or next page.
495 candidates = document.querySelectorAll(options.pattern_selector)
497 # Note: Earlier patterns should be favored.
500 # Search for the prev/next patterns in the following attributes of the
501 # element. `rel` should be kept as the first attribute, since the standard way
502 # of marking up prev/next links (`rel="prev"` and `rel="next"`) should be
503 # favored. Even though some of these attributes only allow a fixed set of
504 # keywords, we pattern-match them anyways since lots of sites don’t follow the
505 # spec and use the attributes arbitrarily.
506 attrs = options.pattern_attrs
509 # Special-case for Google searches.
510 googleLink = document.getElementById("pn#{type}")
511 return googleLink if googleLink
513 # First search in attributes (favoring earlier attributes) as it's likely
514 # that they are more specific than text contexts.
516 for regex in patterns
517 for element in candidates
518 return element if regex.test(element.getAttribute?(attr))
520 # Then search in element contents.
521 for regex in patterns
522 for element in candidates
523 return element if regex.test(element.textContent)
528 utils.simulateMouseEvents(matchingLink, 'click', browserOffset)
529 # When you go to the next page of GitHub’s code search results, the page is
530 # loaded with AJAX. GitHub then annoyingly focuses its search input. This
531 # autofocus cannot be prevented in a reliable way, because the case is
532 # indistinguishable from a button whose job is to focus some text input.
533 # However, in this command we know for sure that we can prevent the next
534 # focus. This must be done _after_ the click has been triggered, since
535 # clicks count as page interactions.
536 vim.markPageInteraction(false)
538 vim.notify(translate("notification.follow_#{type}.none"))
540 commands.focus_text_input = ({vim, count = null}) ->
541 {lastFocusedTextInput} = vim.state
543 if lastFocusedTextInput and utils.isDetached(lastFocusedTextInput)
544 lastFocusedTextInput = null
546 candidates = utils.querySelectorAllDeep(
547 vim.content, 'input, textarea, textbox, [contenteditable]'
549 inputs = Array.prototype.filter.call(candidates, (element) ->
550 return isTextInputElement(element) and utils.area(element) > 0
552 if lastFocusedTextInput and lastFocusedTextInput not in inputs
553 inputs.push(lastFocusedTextInput)
554 inputs.sort((a, b) -> a.tabIndex - b.tabIndex)
556 if inputs.length == 0
557 vim.notify(translate('notification.focus_text_input.none'))
563 when lastFocusedTextInput
564 inputs.indexOf(lastFocusedTextInput) + 1
567 index = Math.min(num, inputs.length) - 1
568 select = (count? or not vim.state.hasFocusedTextInput)
569 utils.focusElement(inputs[index], {select})
570 vim.state.inputs = inputs
572 commands.clear_inputs = ({vim}) ->
573 vim.state.inputs = null
575 commands.move_focus = ({vim, direction}) ->
576 return false unless vim.state.inputs
577 index = vim.state.inputs.indexOf(utils.getActiveElement(vim.content))
578 # If there’s only one input, `<tab>` would cycle to itself, making it feel
579 # like `<tab>` was not working. Then it’s better to let `<tab>` work as it
581 if index == -1 or vim.state.inputs.length <= 1
582 vim.state.inputs = null
586 nextInput = inputs[(index + direction) %% inputs.length]
587 utils.focusElement(nextInput, {select: true})
590 # This is an attempt to enhance Firefox’s native “Find in page” functionality.
591 # Firefox starts searching after the end of the first selection range, or from
592 # the top of the page if there are no selection ranges. If there are frames, the
593 # top-most document in DOM order with selections seems to be used.
595 # Replace the current selection with one single range. (Searching clears the
596 # previous selection anyway.) That single range is either the first visible
597 # range, or a newly created (and collapsed) one at the top of the viewport. This
598 # way we can control where Firefox searches from.
599 commands.find_from_top_of_viewport = ({vim, direction}) ->
600 viewport = viewportUtils.getWindowViewport(vim.content)
602 range = viewportUtils.getFirstVisibleRange(vim.content, viewport)
604 window = range.startContainer.ownerGlobal
605 selection = window.getSelection()
606 utils.clearSelectionDeep(vim.content)
608 # When the next match is in another frame than the current selection (A),
609 # Firefox won’t clear that selection before making a match selection (B) in
610 # the other frame. When searching again, selection B is cleared because
611 # selection A appears further up the viewport. This causes us to search
612 # _again_ from selection A, rather than selection B. In effect, we get stuck
613 # re-selecting selection B over and over. Therefore, collapse the range
614 # first, in case Firefox doesn’t.
616 selection.addRange(range)
617 # Collapsing the range causes backwards search to keep re-selecting the same
618 # match. Therefore, move it one character back.
619 selection.modify('move', 'backward', 'character') if direction == BACKWARD
622 result = viewportUtils.getFirstVisibleText(vim.content, viewport)
624 [textNode, offset] = result
626 utils.clearSelectionDeep(vim.content)
627 window = textNode.ownerGlobal
629 range = window.document.createRange()
630 range.setStart(textNode, offset)
631 range.setEnd(textNode, offset)
632 selection = window.getSelection()
633 selection.addRange(range)
635 commands.esc = (args) ->
637 commands.blur_active_element(args)
639 utils.clearSelectionDeep(vim.content)
641 {document} = vim.content
643 return unless document.fullscreenElement
645 if document.exitFullscreen
646 document.exitFullscreen()
648 document.mozCancelFullScreen()
650 commands.blur_active_element = ({vim}) ->
651 vim.state.explicitBodyFocus = false
652 utils.blurActiveElement(vim.content)
654 helper_create_selection_manager = (vim) ->
655 window = utils.getActiveElement(vim.content)?.ownerGlobal ? vim.content
656 return new SelectionManager(window)
658 commands.enable_caret = ({vim}) ->
659 return unless selectionManager = helper_create_selection_manager(vim)
660 selectionManager.enableCaret()
662 commands.move_caret = ({vim, method, direction, select, count}) ->
663 return unless selectionManager = helper_create_selection_manager(vim)
665 if selectionManager[method]
666 error = selectionManager[method](direction, select)
668 error = selectionManager.moveCaret(method, direction, select)
672 commands.toggle_selection = ({vim, select}) ->
673 return unless selectionManager = helper_create_selection_manager(vim)
675 vim.notify(translate('notification.toggle_selection.enter'))
677 selectionManager.collapse()
679 commands.toggle_selection_direction = ({vim}) ->
680 return unless selectionManager = helper_create_selection_manager(vim)
681 selectionManager.reverseDirection()
683 commands.get_selection = ({vim}) ->
684 return unless selectionManager = helper_create_selection_manager(vim)
685 return selectionManager.selection.toString()
687 commands.collapse_selection = ({vim}) ->
688 return unless selectionManager = helper_create_selection_manager(vim)
689 selectionManager.collapse()
691 commands.clear_selection = ({vim, blur}) ->
692 utils.clearSelectionDeep(vim.content, {blur})
694 commands.modal = ({vim, type, args}) ->
695 return vim.content[type](args...)
697 module.exports = commands