2 # Copyright Simon Lydell 2015, 2016.
4 # This file is part of VimFx.
6 # VimFx is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
11 # VimFx is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with VimFx. If not, see <http://www.gnu.org/licenses/>.
20 # This file is the equivalent to commands.coffee, but for frame scripts,
21 # allowing interaction with web page content. Most “commands” here have the
22 # same name as the command in commands.coffee that calls it. There are also a
23 # few more generalized “commands” used in more than one place.
25 markableElements = require('./markable-elements')
26 prefs = require('./prefs')
27 SelectionManager = require('./selection')
28 translate = require('./translate')
29 utils = require('./utils')
30 viewportUtils = require('./viewport')
32 {FORWARD, BACKWARD} = SelectionManager
33 {isProperLink, isTextInputElement, isTypingElement, isContentEditable} = utils
35 XULDocument = Ci.nsIDOMXULDocument
37 # <http://www.w3.org/html/wg/drafts/html/master/dom.html#wai-aria>
38 CLICKABLE_ARIA_ROLES = [
39 'link', 'button', 'tab'
40 'checkbox', 'radio', 'combobox', 'option', 'slider', 'textbox'
41 'menuitem', 'menuitemcheckbox', 'menuitemradio'
44 createComplementarySelectors = (selectors) ->
47 selectors.map((selector) -> ":not(#{selector})").join('')
50 FOLLOW_DEFAULT_SELECTORS = createComplementarySelectors([
51 'a', 'button', 'input', 'textarea', 'select'
52 '[role]', '[contenteditable]'
55 FOLLOW_CONTEXT_TAGS = [
56 'a', 'button', 'input', 'textarea', 'select'
57 'img', 'audio', 'video', 'canvas', 'embed', 'object'
60 FOLLOW_CONTEXT_SELECTORS = createComplementarySelectors(FOLLOW_CONTEXT_TAGS)
62 FOLLOW_SELECTABLE_SELECTORS =
63 createComplementarySelectors(['div', 'span']).reverse()
67 commands.go_up_path = ({vim, count = 1}) ->
68 {pathname} = vim.content.location
69 newPathname = pathname.replace(/// (?: [^/]+(/|$) ){1,#{count}} $ ///, '')
70 if newPathname == pathname
71 vim.notify(translate('notification.go_up_path.limit'))
73 vim.content.location.pathname = newPathname
75 commands.go_to_root = ({vim}) ->
76 # `.origin` is `'null'` (as a string) on `about:` pages.
77 if "#{vim.content.location.origin}/" in [vim.content.location.href, 'null/']
78 vim.notify(translate('notification.go_up_path.limit'))
80 vim.content.location.href = vim.content.location.origin
82 commands.scroll = (args) ->
84 return unless activeElement = utils.getActiveElement(vim.content)
86 # If no element is focused on the page, the active element is the topmost
87 # `<body>`, and blurring it is a no-op. If it is scrollable, it means that
88 # you can’t blur it in order to scroll `<html>`. Therefore it may only be
89 # scrolled if it has been explicitly focused.
90 if vim.state.scrollableElements.has(activeElement) and
91 (activeElement != vim.content.document.body or
92 vim.state.explicitBodyFocus)
95 vim.state.scrollableElements.filterSuitableDefault()
96 viewportUtils.scroll(element, args)
98 commands.mark_scroll_position = ({vim, keyStr, notify = true}) ->
99 element = vim.state.scrollableElements.filterSuitableDefault()
100 vim.state.marks[keyStr] =
101 if element.ownerDocument.documentElement.localName == 'svg'
102 [element.ownerGlobal.scrollY, element.ownerGlobal.scrollX]
104 [element.scrollTop, element.scrollLeft]
106 vim.notify(translate('notification.mark_scroll_position.success', keyStr))
108 commands.scroll_to_mark = (args) ->
109 {vim, amounts: keyStr} = args
110 unless keyStr of vim.state.marks
111 vim.notify(translate('notification.scroll_to_mark.none', keyStr))
114 args.amounts = vim.state.marks[keyStr]
115 element = vim.state.scrollableElements.filterSuitableDefault()
116 viewportUtils.scroll(element, args)
118 helper_follow = (options, matcher, {vim, pass}) ->
119 {id, combine = true, selectors = FOLLOW_DEFAULT_SELECTORS} = options
120 if vim.content.document instanceof XULDocument
124 pass = if selectors.length == 2 then 'first' else 'single'
126 vim.state.markerElements = [] if pass in ['single', 'first']
129 filter = (element, getElementShape) ->
130 type = matcher({vim, element, getElementShape})
132 type = vim.hintMatcher(id, element, type)
133 if pass == 'complementary'
134 type = if type then null else 'complementary'
137 shape = getElementShape(element)
139 unless shape.nonCoveredPoint then switch
140 # CodeMirror editor uses a tiny hidden textarea positioned at the caret.
141 # Targeting those are the only reliable way of focusing CodeMirror
142 # editors, and doing so without moving the caret.
143 when id == 'normal' and element.localName == 'textarea' and
144 element.ownerGlobal == vim.content
145 rect = element.getBoundingClientRect()
146 # Use `.clientWidth` instead of `rect.width` because the latter includes
147 # the width of the borders of the textarea, which are unreliable.
148 if element.clientWidth <= 1 and rect.height > 0
152 y: rect.top + rect.height / 2
153 offset: {left: 0, top: 0}
155 area: rect.width * rect.height
158 # Putting a large `<input type="file">` inside a smaller wrapper element
159 # with `overflow: hidden;` seems to be a common pattern, used both on
160 # addons.mozilla.org and <https://blueimp.github.io/jQuery-File-Upload/>.
161 when id in ['normal', 'focus'] and element.localName == 'input' and
162 element.type == 'file' and element.parentElement?
163 parentShape = getElementShape(element.parentElement)
164 shape = parentShape if parentShape.area <= shape.area
166 if not shape.nonCoveredPoint and pass == 'complementary'
167 shape = getElementShape(element, -1)
169 return unless shape.nonCoveredPoint
171 text = utils.getText(element)
173 originalRect = element.getBoundingClientRect()
174 length = vim.state.markerElements.push({element, originalRect})
177 combinedArea: shape.area
178 elementIndex: length - 1
182 if wrapper.type == 'link'
186 # Combine links with the same href.
187 if combine and wrapper.type == 'link' and
188 # If the element has an 'onclick' attribute we cannot be sure that all
189 # links with this href actually do the same thing. On some pages, such
190 # as startpage.com, actual proper links have the 'onclick' attribute,
191 # so we can’t exclude such links in `utils.isProperLink`.
192 not element.hasAttribute?('onclick') and
193 # GitHub’s diff expansion buttons are links with both `href` and
194 # `data-url`. They are JavaScript-powered using the latter attribute.
195 not element.hasAttribute?('data-url')
198 wrapper.parentIndex = parent.elementIndex
199 parent.combinedArea += wrapper.shape.area
200 parent.numChildren += 1
202 hrefs[href] = wrapper
207 if pass == 'complementary'
210 selectors[if pass == 'second' then 1 else 0]
212 wrappers: markableElements.find(vim.content, filter, selector)
213 viewport: viewportUtils.getWindowViewport(vim.content)
217 commands.follow = helper_follow.bind(
218 null, {id: 'normal'},
219 ({vim, element, getElementShape}) ->
220 document = element.ownerDocument
221 isXUL = (document instanceof XULDocument)
224 # Bootstrap. Match these before regular links, because especially slider
225 # “buttons” often get the same hint otherwise.
226 when element.hasAttribute?('data-toggle') or
227 element.hasAttribute?('data-dismiss') or
228 element.hasAttribute?('data-slide') or
229 element.hasAttribute?('data-slide-to')
231 when isProperLink(element)
233 when isTypingElement(element)
235 when element.localName in ['a', 'button'] or
236 element.getAttribute?('role') in CLICKABLE_ARIA_ROLES or
237 # <http://www.w3.org/TR/wai-aria/states_and_properties>
238 element.hasAttribute?('aria-controls') or
239 element.hasAttribute?('aria-pressed') or
240 element.hasAttribute?('aria-checked') or
241 (element.hasAttribute?('aria-haspopup') and
242 element.getAttribute?('role') != 'menu')
244 when utils.isFocusable(element) and
245 # Google Drive Documents. The hint for this element would cover the
246 # real hint that allows you to focus the document to start typing.
247 element.id != 'docs-editor'
249 when element != vim.state.scrollableElements.largest and
250 vim.state.scrollableElements.has(element)
252 when element.hasAttribute?('onclick') or
253 element.hasAttribute?('onmousedown') or
254 element.hasAttribute?('onmouseup') or
255 element.hasAttribute?('oncommand') or
257 element.classList?.contains('js-new-tweets-bar') or
258 element.hasAttribute?('data-permalink-path') or
260 element.hasAttribute?('data-app-action') or
261 element.hasAttribute?('data-uri') or
262 element.hasAttribute?('data-page-action') or
263 # Google Drive Document.
264 element.classList?.contains('kix-appview-editor')
266 # Facebook comment fields.
267 when element.parentElement?.classList?.contains('UFIInputContainer')
268 type = 'clickable-special'
269 # Putting markers on `<label>` elements is generally redundant, because
270 # its `<input>` gets one. However, some sites hide the actual `<input>`
271 # but keeps the `<label>` to click, either for styling purposes or to keep
272 # the `<input>` hidden until it is used. In those cases we should add a
273 # marker for the `<label>`. Trying to access `.control` on an element in
274 # `about:config` throws an error, so exclude XUL pages.
275 when not isXUL and element.localName == 'label' and element.control and
276 not getElementShape(element.control).nonCoveredPoint
278 # Last resort checks for elements that might be clickable because of
281 # It is common to listen for clicks on `<html>` or `<body>`. Don’t
282 # waste time on them.
283 element not in [document.documentElement, document.body]) and
284 (utils.includes(element.className, 'button') or
285 utils.includes(element.getAttribute?('aria-label'), 'close') or
286 # Do this last as it’s a potentially expensive check.
287 (utils.hasEventListeners(element, 'click') and
288 # Twitter. The hint for this element would cover the hint for
289 # showing more tweets.
290 not element.classList?.contains('js-new-items-bar-container')))
291 # Make a quick check for likely clickable descendants, to reduce the
292 # number of false positives. the element might be a “button-wrapper” or
293 # a large element with a click-tracking event listener.
294 unless element.querySelector?('a, button, input, [class*=button]')
296 # When viewing an image it should get a marker to toggle zoom. This is the
297 # most unlikely rule to match, so keep it last.
298 when document.body?.childElementCount == 1 and
299 element.localName == 'img' and
300 (element.classList?.contains('overflowing') or
301 element.classList?.contains('shrinkToFit'))
303 type = null if isXUL and element.classList?.contains('textbox-input')
307 commands.follow_in_tab = helper_follow.bind(
308 null, {id: 'tab', selectors: ['a']},
310 type = if isProperLink(element) then 'link' else null
314 commands.follow_copy = helper_follow.bind(
318 when isProperLink(element)
320 when isContentEditable(element)
322 when isTypingElement(element)
329 commands.follow_focus = helper_follow.bind(
330 null, {id: 'focus', combine: false},
333 when element.tabIndex > -1
335 when element != vim.state.scrollableElements.largest and
336 vim.state.scrollableElements.has(element)
343 commands.follow_context = helper_follow.bind(
344 null, {id: 'context', selectors: FOLLOW_CONTEXT_SELECTORS},
347 if element.localName in FOLLOW_CONTEXT_TAGS or
348 utils.hasMarkableTextNode(element)
355 commands.follow_selectable = helper_follow.bind(
356 null, {id: 'selectable', selectors: FOLLOW_SELECTABLE_SELECTORS},
359 if utils.hasMarkableTextNode(element)
366 commands.focus_marker_element = ({vim, elementIndex, browserOffset, options}) ->
367 {element} = vim.state.markerElements[elementIndex]
368 # To be able to focus scrollable elements, `FLAG_BYKEY` _has_ to be used.
369 options.flag = 'FLAG_BYKEY' if vim.state.scrollableElements.has(element)
370 utils.focusElement(element, options)
372 vim.setHover(element, browserOffset)
374 commands.click_marker_element = (
375 {vim, elementIndex, type, browserOffset, preventTargetBlank}
377 {element} = vim.state.markerElements[elementIndex]
378 if element.target == '_blank' and preventTargetBlank
379 targetReset = element.target
381 if type == 'clickable-special'
384 isXUL = (element.ownerDocument instanceof XULDocument)
387 if element.localName == 'tab' then ['mousedown'] else 'click-xul'
388 when type == 'context'
392 utils.simulateMouseEvents(element, sequence, browserOffset)
393 utils.openDropdown(element)
394 element.target = targetReset if targetReset
396 commands.copy_marker_element = ({vim, elementIndex, property}) ->
397 {element} = vim.state.markerElements[elementIndex]
398 utils.writeToClipboard(element[property])
400 commands.element_text_select = ({vim, elementIndex, full, scroll = false}) ->
401 {element} = vim.state.markerElements[elementIndex]
402 window = element.ownerGlobal
403 selection = window.getSelection()
404 range = window.document.createRange()
406 # Try to scroll the element into view, but keep the caret visible.
408 viewport = viewportUtils.getWindowViewport(window)
409 rect = element.getBoundingClientRect()
411 when rect.bottom > viewport.bottom
413 when rect.top < viewport.top and rect.height < viewport.height
419 prefs.root.get('general.smoothScroll') and
420 prefs.root.get('general.smoothScroll.other')
422 element.scrollIntoView({
424 behavior: if smooth then 'smooth' else 'instant'
428 range.selectNodeContents(element)
430 result = viewportUtils.getFirstNonWhitespace(element)
432 [node, offset] = result
433 range.setStart(node, offset)
434 range.setEnd(node, offset)
436 range.setStartBefore(element)
437 range.setEndBefore(element)
439 utils.clearSelectionDeep(vim.content)
441 selection.addRange(range)
443 commands.follow_pattern = ({vim, type, browserOffset, options}) ->
444 {document} = vim.content
446 # If there’s a `<link rel=prev/next>` element we use that.
447 for link in document.head?.getElementsByTagName('link')
448 # Also support `rel=previous`, just like Google.
449 if type == link.rel.toLowerCase().replace(/^previous$/, 'prev')
450 vim.content.location.href = link.href
453 # Otherwise we look for a link or button on the page that seems to go to the
454 # previous or next page.
455 candidates = document.querySelectorAll(options.pattern_selector)
457 # Note: Earlier patterns should be favored.
460 # Search for the prev/next patterns in the following attributes of the
461 # element. `rel` should be kept as the first attribute, since the standard way
462 # of marking up prev/next links (`rel="prev"` and `rel="next"`) should be
463 # favored. Even though some of these attributes only allow a fixed set of
464 # keywords, we pattern-match them anyways since lots of sites don’t follow the
465 # spec and use the attributes arbitrarily.
466 attrs = options.pattern_attrs
469 # First search in attributes (favoring earlier attributes) as it's likely
470 # that they are more specific than text contexts.
472 for regex in patterns
473 for element in candidates
474 return element if regex.test(element.getAttribute?(attr))
476 # Then search in element contents.
477 for regex in patterns
478 for element in candidates
479 return element if regex.test(element.textContent)
484 utils.simulateMouseEvents(matchingLink, 'click', browserOffset)
485 # When you go to the next page of GitHub’s code search results, the page is
486 # loaded with AJAX. GitHub then annoyingly focuses its search input. This
487 # autofocus cannot be prevented in a reliable way, because the case is
488 # indistinguishable from a button whose job is to focus some text input.
489 # However, in this command we know for sure that we can prevent the next
490 # focus. This must be done _after_ the click has been triggered, since
491 # clicks count as page interactions.
492 vim.markPageInteraction(false)
494 vim.notify(translate("notification.follow_#{type}.none"))
496 commands.focus_text_input = ({vim, count = null}) ->
497 {lastFocusedTextInput} = vim.state
499 if lastFocusedTextInput and utils.isDetached(lastFocusedTextInput)
500 lastFocusedTextInput = null
502 candidates = utils.querySelectorAllDeep(
503 vim.content, 'input, textarea, textbox, [contenteditable]'
505 inputs = Array.filter(candidates, (element) ->
506 return isTextInputElement(element) and utils.area(element) > 0
508 if lastFocusedTextInput and lastFocusedTextInput not in inputs
509 inputs.push(lastFocusedTextInput)
510 inputs.sort((a, b) -> a.tabIndex - b.tabIndex)
512 if inputs.length == 0
513 vim.notify(translate('notification.focus_text_input.none'))
519 when lastFocusedTextInput
520 inputs.indexOf(lastFocusedTextInput) + 1
523 index = Math.min(num, inputs.length) - 1
524 select = (count? or not vim.state.hasFocusedTextInput)
525 utils.focusElement(inputs[index], {select})
526 vim.state.inputs = inputs
528 commands.clear_inputs = ({vim}) ->
529 vim.state.inputs = null
531 commands.move_focus = ({vim, direction}) ->
532 return false unless vim.state.inputs
533 index = vim.state.inputs.indexOf(utils.getActiveElement(vim.content))
534 # If there’s only one input, `<tab>` would cycle to itself, making it feel
535 # like `<tab>` was not working. Then it’s better to let `<tab>` work as it
537 if index == -1 or vim.state.inputs.length <= 1
538 vim.state.inputs = null
542 nextInput = inputs[(index + direction) %% inputs.length]
543 utils.focusElement(nextInput, {select: true})
546 # This is an attempt to enhance Firefox’s native “Find in page” functionality.
547 # Firefox starts searching after the end of the first selection range, or from
548 # the top of the page if there are no selection ranges. If there are frames, the
549 # top-most document in DOM order with selections seems to be used.
551 # Replace the current selection with one single range. (Searching clears the
552 # previous selection anyway.) That single range is either the first visible
553 # range, or a newly created (and collapsed) one at the top of the viewport. This
554 # way we can control where Firefox searches from.
555 commands.find_from_top_of_viewport = ({vim, direction}) ->
556 viewport = viewportUtils.getWindowViewport(vim.content)
558 range = viewportUtils.getFirstVisibleRange(vim.content, viewport)
560 window = range.startContainer.ownerGlobal
561 selection = window.getSelection()
562 utils.clearSelectionDeep(vim.content)
564 # When the next match is in another frame than the current selection (A),
565 # Firefox won’t clear that selection before making a match selection (B) in
566 # the other frame. When searching again, selection B is cleared because
567 # selection A appears further up the viewport. This causes us to search
568 # _again_ from selection A, rather than selection B. In effect, we get stuck
569 # re-selecting selection B over and over. Therefore, collapse the range
570 # first, in case Firefox doesn’t.
572 selection.addRange(range)
573 # Collapsing the range causes backwards search to keep re-selecting the same
574 # match. Therefore, move it one character back.
575 selection.modify('move', 'backward', 'character') if direction == BACKWARD
578 result = viewportUtils.getFirstVisibleText(vim.content, viewport)
580 [textNode, offset] = result
582 utils.clearSelectionDeep(vim.content)
583 window = textNode.ownerGlobal
585 range = window.document.createRange()
586 range.setStart(textNode, offset)
587 range.setEnd(textNode, offset)
588 selection = window.getSelection()
589 selection.addRange(range)
591 commands.esc = (args) ->
593 commands.blur_active_element(args)
595 utils.clearSelectionDeep(vim.content)
597 {document} = vim.content
598 if document.exitFullscreen
599 document.exitFullscreen()
601 document.mozCancelFullScreen()
603 commands.blur_active_element = ({vim}) ->
604 vim.state.explicitBodyFocus = false
605 utils.blurActiveElement(vim.content)
607 helper_create_selection_manager = (vim) ->
608 window = utils.getActiveElement(vim.content)?.ownerGlobal ? vim.content
609 return new SelectionManager(window)
611 commands.enable_caret = ({vim}) ->
612 return unless selectionManager = helper_create_selection_manager(vim)
613 selectionManager.enableCaret()
615 commands.move_caret = ({vim, method, direction, select, count}) ->
616 return unless selectionManager = helper_create_selection_manager(vim)
618 if selectionManager[method]
619 error = selectionManager[method](direction, select)
621 error = selectionManager.moveCaret(method, direction, select)
625 commands.toggle_selection = ({vim, select}) ->
626 return unless selectionManager = helper_create_selection_manager(vim)
628 vim.notify(translate('notification.toggle_selection.enter'))
630 selectionManager.collapse()
632 commands.toggle_selection_direction = ({vim}) ->
633 return unless selectionManager = helper_create_selection_manager(vim)
634 selectionManager.reverseDirection()
636 commands.get_selection = ({vim}) ->
637 return unless selectionManager = helper_create_selection_manager(vim)
638 return selectionManager.selection.toString()
640 commands.collapse_selection = ({vim}) ->
641 return unless selectionManager = helper_create_selection_manager(vim)
642 selectionManager.collapse()
644 commands.clear_selection = ({vim}) ->
645 utils.clearSelectionDeep(vim.content)
647 commands.modal = ({vim, type, args}) ->
648 return vim.content[type](args...)
650 module.exports = commands