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 translate = require('./l10n')
27 utils = require('./utils')
29 {isProperLink, isTextInputElement, isTypingElement, isContentEditable} = utils
31 XULDocument = Ci.nsIDOMXULDocument
33 # <http://www.w3.org/html/wg/drafts/html/master/dom.html#wai-aria>
34 CLICKABLE_ARIA_ROLES = [
35 'link', 'button', 'tab'
36 'checkbox', 'radio', 'combobox', 'option', 'slider', 'textbox'
37 'menuitem', 'menuitemcheckbox', 'menuitemradio'
42 commands.go_up_path = ({vim, count = 1}) ->
43 {pathname} = vim.content.location
44 newPathname = pathname.replace(/// (?: /[^/]+ ){1,#{count}} /?$ ///, '')
45 if newPathname == pathname
46 vim.notify(translate('notification.go_up_path.limit'))
48 vim.content.location.pathname = newPathname
50 commands.go_to_root = ({vim}) ->
51 # `.origin` is `'null'` (as a string) on `about:` pages.
52 if "#{vim.content.location.origin}/" in [vim.content.location.href, 'null/']
53 vim.notify(translate('notification.go_up_path.limit'))
55 vim.content.location.href = vim.content.location.origin
57 commands.scroll = (args) ->
59 return unless activeElement = utils.getActiveElement(vim.content)
61 # If no element is focused on the page, the the active element is the
62 # topmost `<body>`, and blurring it is a no-op. If it is scrollable, it
63 # means that you can’t blur it in order to scroll `<html>`. Therefore it may
64 # only be scrolled if it has been explicitly focused.
65 if vim.state.scrollableElements.has(activeElement) and
66 (activeElement != vim.content.document.body or
67 vim.state.explicitBodyFocus)
70 vim.state.scrollableElements.filterSuitableDefault()
71 utils.scroll(element, args)
73 commands.mark_scroll_position = ({vim, keyStr, notify = true}) ->
74 element = vim.state.scrollableElements.filterSuitableDefault()
75 vim.state.marks[keyStr] = [element.scrollTop, element.scrollLeft]
77 vim.notify(translate('notification.mark_scroll_position.success', keyStr))
79 commands.scroll_to_mark = (args) ->
80 {vim, amounts: keyStr} = args
81 unless keyStr of vim.state.marks
82 vim.notify(translate('notification.scroll_to_mark.none', keyStr))
85 args.amounts = vim.state.marks[keyStr]
86 element = vim.state.scrollableElements.filterSuitableDefault()
87 utils.scroll(element, args)
89 helper_follow = ({id, combine = true}, matcher, {vim}) ->
91 vim.state.markerElements = []
93 filter = (element, getElementShape) ->
94 {type, semantic} = matcher({vim, element, getElementShape})
97 {type, semantic} = vim.hintMatcher(id, element, {type, semantic})
100 return unless shape = getElementShape(element)
102 originalRect = element.getBoundingClientRect()
103 length = vim.state.markerElements.push({element, originalRect})
104 wrapper = {type, semantic, shape, elementIndex: length - 1}
106 if wrapper.type == 'link'
110 # Combine links with the same href.
111 if combine and wrapper.type == 'link' and
112 # If the element has an 'onclick' attribute we cannot be sure that all
113 # links with this href actually do the same thing. On some pages, such
114 # as startpage.com, actual proper links have the 'onclick' attribute,
115 # so we can’t exclude such links in `utils.isProperLink`.
116 not element.hasAttribute('onclick') and
117 # GitHub’s diff expansion buttons are links with both `href` and
118 # `data-url`. They are JavaScript-powered using the latter attribute.
119 not element.hasAttribute('data-url')
122 wrapper.parentIndex = parent.elementIndex
123 parent.shape.area += wrapper.shape.area
124 parent.numChildren += 1
126 wrapper.numChildren = 0
127 hrefs[href] = wrapper
131 return hints.getMarkableElementsAndViewport(vim.content, filter)
133 commands.follow = helper_follow.bind(null, {id: 'normal'},
134 ({vim, element, getElementShape}) ->
135 document = element.ownerDocument
136 isXUL = (document instanceof XULDocument)
140 # Bootstrap. Match these before regular links, because especially slider
141 # “buttons” often get the same hint otherwise.
142 when element.hasAttribute('data-toggle') or
143 element.hasAttribute('data-dismiss') or
144 element.hasAttribute('data-slide') or
145 element.hasAttribute('data-slide-to')
146 # Some elements may not be semantic, but _should be_ and still deserve a
149 when isProperLink(element)
151 when isTypingElement(element)
153 when element.getAttribute('role') in CLICKABLE_ARIA_ROLES or
154 # <http://www.w3.org/TR/wai-aria/states_and_properties>
155 element.hasAttribute('aria-controls') or
156 element.hasAttribute('aria-pressed') or
157 element.hasAttribute('aria-checked') or
158 (element.hasAttribute('aria-haspopup') and
159 element.getAttribute('role') != 'menu')
161 when element.tabIndex > -1 and
162 # Google Drive Documents. The hint for this element would cover the
163 # real hint that allows you to focus the document to start typing.
164 element.id != 'docs-editor' and
165 not (isXUL and element.localName.endsWith('box') and
166 element.localName != 'checkbox')
168 unless isXUL or element.localName in ['a', 'input', 'button']
170 when element != vim.state.scrollableElements.largest and
171 vim.state.scrollableElements.has(element)
173 when element.hasAttribute('onclick') or
174 element.hasAttribute('onmousedown') or
175 element.hasAttribute('onmouseup') or
176 element.hasAttribute('oncommand') or
178 element.classList.contains('js-new-tweets-bar') or
180 element.hasAttribute('data-app-action') or
181 element.hasAttribute('data-uri') or
182 element.hasAttribute('data-page-action') or
184 element.classList.contains('CodeMirror-scroll') or
185 # Google Drive Document.
186 element.classList.contains('kix-appview-editor')
189 # Facebook comment fields.
190 when element.parentElement?.classList.contains('UFIInputContainer')
191 type = 'clickable-special'
192 # Putting markers on `<label>` elements is generally redundant, because
193 # its `<input>` gets one. However, some sites hide the actual `<input>`
194 # but keeps the `<label>` to click, either for styling purposes or to keep
195 # the `<input>` hidden until it is used. In those cases we should add a
196 # marker for the `<label>`.
197 when element.localName == 'label'
200 document.getElementById(element.htmlFor)
202 element.querySelector('input, textarea, select')
203 if input and not getElementShape(input)
205 # Last resort checks for elements that might be clickable because of
208 # It is common to listen for clicks on `<html>` or `<body>`. Don’t
209 # waste time on them.
210 element not in [document.documentElement, document.body]) and
211 (utils.includes(element.className, 'button') or
212 utils.includes(element.getAttribute('aria-label'), 'close') or
213 # Do this last as it’s a potentially expensive check.
214 utils.hasEventListeners(element, 'click'))
215 # Make a quick check for likely clickable descendants, to reduce the
216 # number of false positives. the element might be a “button-wrapper” or
217 # a large element with a click-tracking event listener.
218 unless element.querySelector('a, button, input, [class*=button]')
221 # When viewing an image it should get a marker to toggle zoom. This is the
222 # most unlikely rule to match, so keep it last.
223 when document.body?.childElementCount == 1 and
224 element.localName == 'img' and
225 (element.classList.contains('overflowing') or
226 element.classList.contains('shrinkToFit'))
228 type = null if isXUL and element.classList.contains('textbox-input')
229 return {type, semantic}
232 commands.follow_in_tab = helper_follow.bind(null, {id: 'tab'},
234 type = if isProperLink(element) then 'link' else null
235 return {type, semantic: true}
238 commands.follow_copy = helper_follow.bind(null, {id: 'copy'},
241 when isProperLink(element)
243 when isContentEditable(element)
245 when isTypingElement(element)
249 return {type, semantic: true}
252 commands.follow_focus = helper_follow.bind(null, {id: 'focus', combine: false},
255 when element.tabIndex > -1
257 when element != vim.state.scrollableElements.largest and
258 vim.state.scrollableElements.has(element)
262 return {type, semantic: true}
265 commands.focus_marker_element = ({vim, elementIndex, options}) ->
266 {element} = vim.state.markerElements[elementIndex]
267 # To be able to focus scrollable elements, `FLAG_BYKEY` _has_ to be used.
268 options.flag = 'FLAG_BYKEY' if vim.state.scrollableElements.has(element)
269 utils.focusElement(element, options)
271 vim.setHover(element)
273 commands.click_marker_element = (args) ->
274 {vim, elementIndex, type, preventTargetBlank} = args
275 {element} = vim.state.markerElements[elementIndex]
276 if element.target == '_blank' and preventTargetBlank
277 targetReset = element.target
279 if type == 'clickable-special'
282 utils.simulateMouseEvents(element, 'click')
283 element.target = targetReset if targetReset
285 commands.copy_marker_element = ({vim, elementIndex, property}) ->
286 {element} = vim.state.markerElements[elementIndex]
287 utils.writeToClipboard(element[property])
289 commands.follow_pattern = ({vim, type, options}) ->
290 {document} = vim.content
292 # If there’s a `<link rel=prev/next>` element we use that.
293 for link in document.head?.getElementsByTagName('link')
294 # Also support `rel=previous`, just like Google.
295 if type == link.rel.toLowerCase().replace(/^previous$/, 'prev')
296 vim.content.location.href = link.href
299 # Otherwise we look for a link or button on the page that seems to go to the
300 # previous or next page.
301 candidates = document.querySelectorAll(options.pattern_selector)
303 # Note: Earlier patterns should be favored.
306 # Search for the prev/next patterns in the following attributes of the
307 # element. `rel` should be kept as the first attribute, since the standard way
308 # of marking up prev/next links (`rel="prev"` and `rel="next"`) should be
309 # favored. Even though some of these attributes only allow a fixed set of
310 # keywords, we pattern-match them anyways since lots of sites don’t follow the
311 # spec and use the attributes arbitrarily.
312 attrs = options.pattern_attrs
315 # First search in attributes (favoring earlier attributes) as it's likely
316 # that they are more specific than text contexts.
318 for regex in patterns
319 for element in candidates
320 return element if regex.test(element.getAttribute(attr))
322 # Then search in element contents.
323 for regex in patterns
324 for element in candidates
325 return element if regex.test(element.textContent)
330 utils.simulateMouseEvents(matchingLink, 'click')
331 # When you go to the next page of GitHub’s code search results, the page is
332 # loaded with AJAX. GitHub then annoyingly focuses its search input. This
333 # autofocus cannot be prevented in a reliable way, because the case is
334 # indistinguishable from a button whose job is to focus some text input.
335 # However, in this command we know for sure that we can prevent the next
336 # focus. This must be done _after_ the click has been triggered, since
337 # clicks count as page interactions.
338 vim.markPageInteraction(false)
340 vim.notify(translate("notification.follow_#{type}.none"))
342 commands.focus_text_input = ({vim, count = null}) ->
343 {lastFocusedTextInput} = vim.state
344 candidates = utils.querySelectorAllDeep(
345 vim.content, 'input, textarea, [contenteditable]'
347 inputs = Array.filter(candidates, (element) ->
348 return isTextInputElement(element) and utils.area(element) > 0
350 if lastFocusedTextInput and lastFocusedTextInput not in inputs
351 inputs.push(lastFocusedTextInput)
352 inputs.sort((a, b) -> a.tabIndex - b.tabIndex)
354 if inputs.length == 0
355 vim.notify(translate('notification.focus_text_input.none'))
361 when lastFocusedTextInput
362 inputs.indexOf(lastFocusedTextInput) + 1
365 index = Math.min(num, inputs.length) - 1
366 select = (count? or not vim.state.hasFocusedTextInput)
367 utils.focusElement(inputs[index], {select})
368 vim.state.inputs = inputs
370 commands.clear_inputs = ({vim}) ->
371 vim.state.inputs = null
373 commands.move_focus = ({vim, direction}) ->
374 return false unless vim.state.inputs
375 index = vim.state.inputs.indexOf(utils.getActiveElement(vim.content))
376 # If there’s only one input, `<tab>` would cycle to itself, making it feel
377 # like `<tab>` was not working. Then it’s better to let `<tab>` work as it
379 if index == -1 or vim.state.inputs.length <= 1
380 vim.state.inputs = null
384 nextInput = inputs[(index + direction) %% inputs.length]
385 utils.focusElement(nextInput, {select: true})
388 commands.esc = (args) ->
390 commands.blur_active_element(args)
393 {document} = vim.content
394 if document.exitFullscreen
395 document.exitFullscreen()
397 document.mozCancelFullScreen()
399 commands.blur_active_element = ({vim}) ->
400 vim.state.explicitBodyFocus = false
401 utils.blurActiveElement(vim.content)
403 module.exports = commands