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 hints = require('./hints')
26 prefs = require('./prefs')
27 SelectionManager = require('./selection')
28 translate = require('./l10n')
29 utils = require('./utils')
31 {isProperLink, isTextInputElement, isTypingElement, isContentEditable} = utils
33 XULDocument = Ci.nsIDOMXULDocument
35 # <http://www.w3.org/html/wg/drafts/html/master/dom.html#wai-aria>
36 CLICKABLE_ARIA_ROLES = [
37 'link', 'button', 'tab'
38 'checkbox', 'radio', 'combobox', 'option', 'slider', 'textbox'
39 'menuitem', 'menuitemcheckbox', 'menuitemradio'
44 commands.go_up_path = ({vim, count = 1}) ->
45 {pathname} = vim.content.location
46 newPathname = pathname.replace(/// (?: /[^/]+ ){1,#{count}} /?$ ///, '')
47 if newPathname == pathname
48 vim.notify(translate('notification.go_up_path.limit'))
50 vim.content.location.pathname = newPathname
52 commands.go_to_root = ({vim}) ->
53 # `.origin` is `'null'` (as a string) on `about:` pages.
54 if "#{vim.content.location.origin}/" in [vim.content.location.href, 'null/']
55 vim.notify(translate('notification.go_up_path.limit'))
57 vim.content.location.href = vim.content.location.origin
59 commands.scroll = (args) ->
61 return unless activeElement = utils.getActiveElement(vim.content)
63 # If no element is focused on the page, the the active element is the
64 # topmost `<body>`, and blurring it is a no-op. If it is scrollable, it
65 # means that you can’t blur it in order to scroll `<html>`. Therefore it may
66 # only be scrolled if it has been explicitly focused.
67 if vim.state.scrollableElements.has(activeElement) and
68 (activeElement != vim.content.document.body or
69 vim.state.explicitBodyFocus)
72 vim.state.scrollableElements.filterSuitableDefault()
73 utils.scroll(element, args)
75 commands.mark_scroll_position = ({vim, keyStr, notify = true}) ->
76 element = vim.state.scrollableElements.filterSuitableDefault()
77 vim.state.marks[keyStr] = [element.scrollTop, element.scrollLeft]
79 vim.notify(translate('notification.mark_scroll_position.success', keyStr))
81 commands.scroll_to_mark = (args) ->
82 {vim, amounts: keyStr} = args
83 unless keyStr of vim.state.marks
84 vim.notify(translate('notification.scroll_to_mark.none', keyStr))
87 args.amounts = vim.state.marks[keyStr]
88 element = vim.state.scrollableElements.filterSuitableDefault()
89 utils.scroll(element, args)
91 helper_follow = ({id, combine = true}, matcher, args) ->
92 {vim, markEverything = false} = args
94 vim.state.markerElements = []
96 filter = (element, getElementShape) ->
97 {type, semantic} = matcher({vim, element, getElementShape})
99 if markEverything and not type
104 {type, semantic} = vim.hintMatcher(id, element, {type, semantic})
107 shape = getElementShape(element)
109 # CodeMirror editor uses a tiny hidden textarea positioned at the caret.
110 # Targeting those are the only reliable way of focusing CodeMirror editors,
111 # and doing so without moving the caret.
112 if not shape and id == 'normal' and element.nodeName == 'TEXTAREA' and
113 element.ownerGlobal == vim.content
114 rect = element.getBoundingClientRect()
115 # Use `.clientWidth` instead of `rect.width` because the latter includes
116 # the width of the borders of the textarea, which are unreliable.
117 if element.clientWidth == 1 and rect.height > 0
121 y: rect.top + rect.height / 2
122 offset: {left: 0, top: 0}
124 area: rect.width * rect.height
129 originalRect = element.getBoundingClientRect()
130 length = vim.state.markerElements.push({element, originalRect})
131 wrapper = {type, semantic, shape, elementIndex: length - 1}
133 if wrapper.type == 'link'
137 # Combine links with the same href.
138 if combine and wrapper.type == 'link' and
139 # If the element has an 'onclick' attribute we cannot be sure that all
140 # links with this href actually do the same thing. On some pages, such
141 # as startpage.com, actual proper links have the 'onclick' attribute,
142 # so we can’t exclude such links in `utils.isProperLink`.
143 not element.hasAttribute?('onclick') and
144 # GitHub’s diff expansion buttons are links with both `href` and
145 # `data-url`. They are JavaScript-powered using the latter attribute.
146 not element.hasAttribute?('data-url')
149 wrapper.parentIndex = parent.elementIndex
150 parent.shape.area += wrapper.shape.area
151 parent.numChildren += 1
153 wrapper.numChildren = 0
154 hrefs[href] = wrapper
159 viewport: utils.getWindowViewport(vim.content)
160 wrappers: hints.getMarkableElements(vim.content, filter)
163 commands.follow = helper_follow.bind(null, {id: 'normal'},
164 ({vim, element, getElementShape}) ->
165 document = element.ownerDocument
166 isXUL = (document instanceof XULDocument)
170 # Bootstrap. Match these before regular links, because especially slider
171 # “buttons” often get the same hint otherwise.
172 when element.hasAttribute?('data-toggle') or
173 element.hasAttribute?('data-dismiss') or
174 element.hasAttribute?('data-slide') or
175 element.hasAttribute?('data-slide-to')
176 # Some elements may not be semantic, but _should be_ and still deserve a
179 when isProperLink(element)
181 when isTypingElement(element)
183 when element.getAttribute?('role') in CLICKABLE_ARIA_ROLES or
184 # <http://www.w3.org/TR/wai-aria/states_and_properties>
185 element.hasAttribute?('aria-controls') or
186 element.hasAttribute?('aria-pressed') or
187 element.hasAttribute?('aria-checked') or
188 (element.hasAttribute?('aria-haspopup') and
189 element.getAttribute?('role') != 'menu')
191 when utils.isFocusable(element) and
192 # Google Drive Documents. The hint for this element would cover the
193 # real hint that allows you to focus the document to start typing.
194 element.id != 'docs-editor'
196 unless isXUL or element.localName in ['a', 'input', 'button']
198 when element != vim.state.scrollableElements.largest and
199 vim.state.scrollableElements.has(element)
201 when element.hasAttribute?('onclick') or
202 element.hasAttribute?('onmousedown') or
203 element.hasAttribute?('onmouseup') or
204 element.hasAttribute?('oncommand') or
206 element.classList?.contains('js-new-tweets-bar') or
208 element.hasAttribute?('data-app-action') or
209 element.hasAttribute?('data-uri') or
210 element.hasAttribute?('data-page-action') or
211 # Google Drive Document.
212 element.classList?.contains('kix-appview-editor')
215 # Facebook comment fields.
216 when element.parentElement?.classList?.contains('UFIInputContainer')
217 type = 'clickable-special'
218 # Putting markers on `<label>` elements is generally redundant, because
219 # its `<input>` gets one. However, some sites hide the actual `<input>`
220 # but keeps the `<label>` to click, either for styling purposes or to keep
221 # the `<input>` hidden until it is used. In those cases we should add a
222 # marker for the `<label>`.
223 when element.localName == 'label'
226 document.getElementById?(element.htmlFor)
228 element.querySelector?('input, textarea, select')
229 if input and not getElementShape(input)
231 # Last resort checks for elements that might be clickable because of
234 # It is common to listen for clicks on `<html>` or `<body>`. Don’t
235 # waste time on them.
236 element not in [document.documentElement, document.body]) and
237 (utils.includes(element.className, 'button') or
238 utils.includes(element.getAttribute?('aria-label'), 'close') or
239 # Do this last as it’s a potentially expensive check.
240 utils.hasEventListeners(element, 'click'))
241 # Make a quick check for likely clickable descendants, to reduce the
242 # number of false positives. the element might be a “button-wrapper” or
243 # a large element with a click-tracking event listener.
244 unless element.querySelector?('a, button, input, [class*=button]')
247 # When viewing an image it should get a marker to toggle zoom. This is the
248 # most unlikely rule to match, so keep it last.
249 when document.body?.childElementCount == 1 and
250 element.localName == 'img' and
251 (element.classList?.contains('overflowing') or
252 element.classList?.contains('shrinkToFit'))
254 type = null if isXUL and element.classList?.contains('textbox-input')
255 return {type, semantic}
258 commands.follow_in_tab = helper_follow.bind(null, {id: 'tab'},
260 type = if isProperLink(element) then 'link' else null
261 return {type, semantic: true}
264 commands.follow_copy = helper_follow.bind(null, {id: 'copy'},
267 when isProperLink(element)
269 when isContentEditable(element)
271 when isTypingElement(element)
275 return {type, semantic: true}
278 commands.follow_focus = helper_follow.bind(null, {id: 'focus', combine: false},
281 when element.tabIndex > -1
283 when element != vim.state.scrollableElements.largest and
284 vim.state.scrollableElements.has(element)
288 return {type, semantic: true}
291 commands.follow_selectable = helper_follow.bind(null, {id: 'selectable'},
293 isRelevantTextNode = (node) ->
294 # Ignore whitespace-only text nodes, and single-letter ones (which are
295 # common in many syntax highlighters).
296 return node.nodeType == 3 and node.data.trim().length > 1
298 if Array.some(element.childNodes, isRelevantTextNode)
302 return {type, semantic: true}
305 commands.focus_marker_element = ({vim, elementIndex, options}) ->
306 {element} = vim.state.markerElements[elementIndex]
307 # To be able to focus scrollable elements, `FLAG_BYKEY` _has_ to be used.
308 options.flag = 'FLAG_BYKEY' if vim.state.scrollableElements.has(element)
309 utils.focusElement(element, options)
311 vim.setHover(element)
313 commands.click_marker_element = (args) ->
314 {vim, elementIndex, type, preventTargetBlank} = args
315 {element} = vim.state.markerElements[elementIndex]
316 if element.target == '_blank' and preventTargetBlank
317 targetReset = element.target
319 if type == 'clickable-special'
322 isXUL = (element.ownerDocument instanceof XULDocument)
325 if element.localName == 'tab' then ['mousedown'] else 'click-xul'
328 utils.simulateMouseEvents(element, sequence)
329 element.target = targetReset if targetReset
331 commands.copy_marker_element = ({vim, elementIndex, property}) ->
332 {element} = vim.state.markerElements[elementIndex]
333 utils.writeToClipboard(element[property])
335 commands.element_text_select = ({vim, elementIndex, full}) ->
336 {element} = vim.state.markerElements[elementIndex]
337 window = element.ownerGlobal
338 selection = window.getSelection()
339 range = window.document.createRange()
342 range.selectNodeContents(element)
344 # Try to scroll the element into view, but keep the caret visible.
345 viewport = viewportUtils.getWindowViewport(window)
346 rect = element.getBoundingClientRect()
348 when rect.bottom > viewport.bottom
350 when rect.top < viewport.top and rect.height < viewport.height
356 prefs.root.get('general.smoothScroll') and
357 prefs.root.get('general.smoothScroll.other')
359 element.scrollIntoView({
361 behavior: if smooth then 'smooth' else 'instant'
365 result = utils.getFirstNonWhitespace(element)
367 [node, offset] = result
368 range.setStart(node, offset)
369 range.setEnd(node, offset)
371 utils.clearSelectionDeep(vim.content)
373 selection.addRange(range)
375 commands.follow_pattern = ({vim, type, options}) ->
376 {document} = vim.content
378 # If there’s a `<link rel=prev/next>` element we use that.
379 for link in document.head?.getElementsByTagName('link')
380 # Also support `rel=previous`, just like Google.
381 if type == link.rel.toLowerCase().replace(/^previous$/, 'prev')
382 vim.content.location.href = link.href
385 # Otherwise we look for a link or button on the page that seems to go to the
386 # previous or next page.
387 candidates = document.querySelectorAll(options.pattern_selector)
389 # Note: Earlier patterns should be favored.
392 # Search for the prev/next patterns in the following attributes of the
393 # element. `rel` should be kept as the first attribute, since the standard way
394 # of marking up prev/next links (`rel="prev"` and `rel="next"`) should be
395 # favored. Even though some of these attributes only allow a fixed set of
396 # keywords, we pattern-match them anyways since lots of sites don’t follow the
397 # spec and use the attributes arbitrarily.
398 attrs = options.pattern_attrs
401 # First search in attributes (favoring earlier attributes) as it's likely
402 # that they are more specific than text contexts.
404 for regex in patterns
405 for element in candidates
406 return element if regex.test(element.getAttribute?(attr))
408 # Then search in element contents.
409 for regex in patterns
410 for element in candidates
411 return element if regex.test(element.textContent)
416 utils.simulateMouseEvents(matchingLink, 'click')
417 # When you go to the next page of GitHub’s code search results, the page is
418 # loaded with AJAX. GitHub then annoyingly focuses its search input. This
419 # autofocus cannot be prevented in a reliable way, because the case is
420 # indistinguishable from a button whose job is to focus some text input.
421 # However, in this command we know for sure that we can prevent the next
422 # focus. This must be done _after_ the click has been triggered, since
423 # clicks count as page interactions.
424 vim.markPageInteraction(false)
426 vim.notify(translate("notification.follow_#{type}.none"))
428 commands.focus_text_input = ({vim, count = null}) ->
429 {lastFocusedTextInput} = vim.state
431 if lastFocusedTextInput and utils.isDetached(lastFocusedTextInput)
432 lastFocusedTextInput = null
434 candidates = utils.querySelectorAllDeep(
435 vim.content, 'input, textarea, textbox, [contenteditable]'
437 inputs = Array.filter(candidates, (element) ->
438 return isTextInputElement(element) and utils.area(element) > 0
440 if lastFocusedTextInput and lastFocusedTextInput not in inputs
441 inputs.push(lastFocusedTextInput)
442 inputs.sort((a, b) -> a.tabIndex - b.tabIndex)
444 if inputs.length == 0
445 vim.notify(translate('notification.focus_text_input.none'))
451 when lastFocusedTextInput
452 inputs.indexOf(lastFocusedTextInput) + 1
455 index = Math.min(num, inputs.length) - 1
456 select = (count? or not vim.state.hasFocusedTextInput)
457 utils.focusElement(inputs[index], {select})
458 vim.state.inputs = inputs
460 commands.clear_inputs = ({vim}) ->
461 vim.state.inputs = null
463 commands.move_focus = ({vim, direction}) ->
464 return false unless vim.state.inputs
465 index = vim.state.inputs.indexOf(utils.getActiveElement(vim.content))
466 # If there’s only one input, `<tab>` would cycle to itself, making it feel
467 # like `<tab>` was not working. Then it’s better to let `<tab>` work as it
469 if index == -1 or vim.state.inputs.length <= 1
470 vim.state.inputs = null
474 nextInput = inputs[(index + direction) %% inputs.length]
475 utils.focusElement(nextInput, {select: true})
478 commands.esc = (args) ->
480 commands.blur_active_element(args)
482 utils.clearSelectionDeep(vim.content)
484 {document} = vim.content
485 if document.exitFullscreen
486 document.exitFullscreen()
488 document.mozCancelFullScreen()
490 commands.blur_active_element = ({vim}) ->
491 vim.state.explicitBodyFocus = false
492 utils.blurActiveElement(vim.content)
494 helper_create_selection_manager = (vim) ->
495 window = utils.getActiveElement(vim.content)?.ownerGlobal ? vim.content
496 return new SelectionManager(window)
498 commands.enable_caret = ({vim}) ->
499 return unless selectionManager = helper_create_selection_manager(vim)
500 selectionManager.enableCaret()
502 commands.move_caret = ({vim, method, direction, select, count}) ->
503 return unless selectionManager = helper_create_selection_manager(vim)
505 if selectionManager[method]
506 error = selectionManager[method](direction, select)
508 error = selectionManager.moveCaret(method, direction, select)
512 commands.toggle_selection = ({vim, select}) ->
513 return unless selectionManager = helper_create_selection_manager(vim)
515 vim.notify(translate('notification.toggle_selection.enter'))
517 selectionManager.collapse()
519 commands.toggle_selection_direction = ({vim}) ->
520 return unless selectionManager = helper_create_selection_manager(vim)
521 selectionManager.reverseDirection()
523 commands.get_selection = ({vim}) ->
524 return unless selectionManager = helper_create_selection_manager(vim)
525 return selectionManager.selection.toString()
527 commands.collapse_selection = ({vim}) ->
528 return unless selectionManager = helper_create_selection_manager(vim)
529 selectionManager.collapse()
531 commands.clear_selection = ({vim}) ->
532 utils.clearSelectionDeep(vim.content)
534 module.exports = commands