2 # Copyright Simon Lydell 2015.
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
35 commands.go_up_path = ({vim, count = 1}) ->
36 {pathname} = vim.content.location
37 newPathname = pathname.replace(/// (?: /[^/]+ ){1,#{count}} /?$ ///, '')
38 if newPathname == pathname
39 vim.notify(translate('notification.go_up_path.limit'))
41 vim.content.location.pathname = newPathname
43 commands.go_to_root = ({vim}) ->
44 # `.origin` is `'null'` (as a string) on `about:` pages.
45 if "#{vim.content.location.origin}/" in [vim.content.location.href, 'null/']
46 vim.notify(translate('notification.go_up_path.limit'))
48 vim.content.location.href = vim.content.location.origin
50 commands.scroll = (args) ->
52 activeElement = utils.getActiveElement(vim.content)
54 # If no element is focused on the page, the the active element is the
55 # topmost `<body>`, and blurring it is a no-op. If it is scrollable, it
56 # means that you can’t blur it in order to scroll `<html>`. Therefore it may
57 # only be scrolled if it has been explicitly focused.
58 if vim.state.scrollableElements.has(activeElement) and
59 (activeElement != vim.content.document.body or
60 vim.state.explicitBodyFocus)
63 vim.state.scrollableElements.filterSuitableDefault()
64 utils.scroll(element, args)
66 commands.mark_scroll_position = ({vim, keyStr, notify = true}) ->
67 element = vim.state.scrollableElements.filterSuitableDefault()
68 vim.state.marks[keyStr] = [element.scrollTop, element.scrollLeft]
70 vim.notify(translate('notification.mark_scroll_position.success', keyStr))
72 commands.scroll_to_mark = (args) ->
73 {vim, amounts: keyStr} = args
74 unless keyStr of vim.state.marks
75 vim.notify(translate('notification.scroll_to_mark.none', keyStr))
78 args.amounts = vim.state.marks[keyStr]
79 element = vim.state.scrollableElements.filterSuitableDefault()
80 utils.scroll(element, args)
82 helper_follow = ({id, combine = true}, matcher, {vim}) ->
84 vim.state.markerElements = []
86 filter = (element, getElementShape) ->
87 {type, semantic} = matcher({vim, element, getElementShape})
89 customMatcher = FRAME_SCRIPT_ENVIRONMENT.VimFxHintMatcher
91 {type, semantic} = customMatcher(id, element, {type, semantic})
94 return unless shape = getElementShape(element)
96 length = vim.state.markerElements.push(element)
97 wrapper = {type, semantic, shape, elementIndex: length - 1}
99 if wrapper.type == 'link'
103 # Combine links with the same href.
104 if combine and wrapper.type == 'link' and
105 # If the element has an 'onclick' attribute we cannot be sure that all
106 # links with this href actually do the same thing. On some pages, such
107 # as startpage.com, actual proper links have the 'onclick' attribute,
108 # so we can’t exclude such links in `utils.isProperLink`.
109 not element.hasAttribute('onclick')
112 wrapper.parentIndex = parent.elementIndex
113 parent.shape.area += wrapper.shape.area
116 wrapper.numChildren = 0
117 hrefs[href] = wrapper
121 return hints.getMarkableElementsAndViewport(vim.content, filter)
123 commands.follow = helper_follow.bind(null, {id: 'normal'},
124 ({vim, element, getElementShape}) ->
125 document = element.ownerDocument
126 isXUL = (document instanceof XULDocument)
130 when isProperLink(element)
132 when isTypingElement(element)
134 when element.tabIndex > -1 and
135 not (isXUL and element.nodeName.endsWith('box') and
136 element.nodeName != 'checkbox')
138 unless isXUL or element.nodeName in ['A', 'INPUT', 'BUTTON']
140 when element != vim.state.scrollableElements.largest and
141 vim.state.scrollableElements.has(element)
143 when element.hasAttribute('onclick') or
144 element.hasAttribute('onmousedown') or
145 element.hasAttribute('onmouseup') or
146 element.hasAttribute('oncommand') or
147 # Clickable ARIA roles:
148 # <http://www.w3.org/html/wg/drafts/html/master/dom.html#wai-aria>
149 element.getAttribute('role') in [
150 'link', 'button', 'tab'
151 'checkbox', 'radio', 'combobox', 'option', 'slider', 'textbox'
152 'menuitem', 'menuitemcheckbox', 'menuitemradio'
154 # Twitter special-case.
155 element.classList.contains('js-new-tweets-bar') or
156 # Feedly special-case.
157 element.hasAttribute('data-app-action') or
158 element.hasAttribute('data-uri') or
159 element.hasAttribute('data-page-action')
162 # Facebook special-case (comment fields).
163 when element.parentElement?.classList.contains('UFIInputContainer')
164 type = 'clickable-special'
165 # Putting markers on `<label>` elements is generally redundant, because
166 # its `<input>` gets one. However, some sites hide the actual `<input>`
167 # but keeps the `<label>` to click, either for styling purposes or to keep
168 # the `<input>` hidden until it is used. In those cases we should add a
169 # marker for the `<label>`.
170 when element.nodeName == 'LABEL'
173 document.getElementById(element.htmlFor)
175 element.querySelector('input, textarea, select')
176 if input and not getElementShape(input)
178 # Elements that have “button” somewhere in the class might be clickable,
179 # unless they contain a real link or button or yet an element with
180 # “button” somewhere in the class, in which case they likely are
181 # “button-wrapper”s. (`<SVG element>.className` is not a string!)
182 when not isXUL and typeof element.className == 'string' and
183 element.className.toLowerCase().includes('button')
184 unless element.querySelector('a, button, input, [class*=button]')
187 # When viewing an image it should get a marker to toggle zoom.
188 when document.body?.childElementCount == 1 and
189 element.nodeName == 'IMG' and
190 (element.classList.contains('overflowing') or
191 element.classList.contains('shrinkToFit'))
193 return {type, semantic}
196 commands.follow_in_tab = helper_follow.bind(null, {id: 'tab'},
198 type = if isProperLink(element) then 'link' else null
199 return {type, semantic: true}
202 commands.follow_copy = helper_follow.bind(null, {id: 'copy'},
205 when isProperLink(element) then 'link'
206 when isContentEditable(element) then 'contenteditable'
207 when isTypingElement(element) then 'text'
209 return {type, semantic: true}
212 commands.follow_focus = helper_follow.bind(null, {id: 'focus', combine: false},
215 when element.tabIndex > -1
217 when element != vim.state.scrollableElements.largest and
218 vim.state.scrollableElements.has(element)
222 return {type, semantic: true}
225 commands.focus_marker_element = ({vim, elementIndex, options}) ->
226 element = vim.state.markerElements[elementIndex]
227 # To be able to focus scrollable elements, `FLAG_BYKEY` _has_ to be used.
228 options.flag = 'FLAG_BYKEY' if vim.state.scrollableElements.has(element)
229 utils.focusElement(element, options)
231 commands.click_marker_element = (args) ->
232 {vim, elementIndex, type, preventTargetBlank} = args
233 element = vim.state.markerElements[elementIndex]
234 if element.target == '_blank' and preventTargetBlank
235 targetReset = element.target
237 if type == 'clickable-special'
240 utils.simulateClick(element)
241 element.target = targetReset if targetReset
243 commands.copy_marker_element = ({vim, elementIndex, property}) ->
244 element = vim.state.markerElements[elementIndex]
245 utils.writeToClipboard(element[property])
247 commands.follow_pattern = ({vim, type, options}) ->
248 {document} = vim.content
250 # If there’s a `<link rel=prev/next>` element we use that.
251 for link in document.head?.getElementsByTagName('link')
252 # Also support `rel=previous`, just like Google.
253 if type == link.rel.toLowerCase().replace(/^previous$/, 'prev')
254 vim.content.location.href = link.href
257 # Otherwise we look for a link or button on the page that seems to go to the
258 # previous or next page.
259 candidates = document.querySelectorAll(options.pattern_selector)
261 # Note: Earlier patterns should be favored.
264 # Search for the prev/next patterns in the following attributes of the
265 # element. `rel` should be kept as the first attribute, since the standard way
266 # of marking up prev/next links (`rel="prev"` and `rel="next"`) should be
267 # favored. Even though some of these attributes only allow a fixed set of
268 # keywords, we pattern-match them anyways since lots of sites don’t follow the
269 # spec and use the attributes arbitrarily.
270 attrs = options.pattern_attrs
273 # First search in attributes (favoring earlier attributes) as it's likely
274 # that they are more specific than text contexts.
276 for regex in patterns
277 for element in candidates
278 return element if regex.test(element.getAttribute(attr))
280 # Then search in element contents.
281 for regex in patterns
282 for element in candidates
283 return element if regex.test(element.textContent)
288 utils.simulateClick(matchingLink)
290 vim.notify(translate("notification.follow_#{type}.none"))
292 commands.focus_text_input = ({vim, count = null}) ->
293 {lastFocusedTextInput} = vim.state
294 candidates = utils.querySelectorAllDeep(
295 vim.content, 'input, textarea, [contenteditable]'
297 inputs = Array.filter(candidates, (element) ->
298 return isTextInputElement(element) and utils.area(element) > 0
300 if lastFocusedTextInput and lastFocusedTextInput not in inputs
301 inputs.push(lastFocusedTextInput)
302 inputs.sort((a, b) -> a.tabIndex - b.tabIndex)
304 if inputs.length == 0
305 vim.notify(translate('notification.focus_text_input.none'))
311 when lastFocusedTextInput
312 inputs.indexOf(lastFocusedTextInput) + 1
315 index = Math.min(num, inputs.length) - 1
316 select = (count? or not vim.state.hasFocusedTextInput)
317 utils.focusElement(inputs[index], {select})
318 vim.state.inputs = inputs
320 commands.clear_inputs = ({vim}) ->
321 vim.state.inputs = null
323 commands.move_focus = ({vim, direction}) ->
324 return false unless vim.state.inputs
325 index = vim.state.inputs.indexOf(utils.getActiveElement(vim.content))
326 # If there’s only one input, `<tab>` would cycle to itself, making it feel
327 # like `<tab>` was not working. Then it’s better to let `<tab>` work as it
329 if index == -1 or vim.state.inputs.length <= 1
330 vim.state.inputs = null
334 nextInput = inputs[(index + direction) %% inputs.length]
335 utils.focusElement(nextInput, {select: true})
338 commands.esc = (args) ->
339 commands.blur_active_element(args)
341 {document} = args.vim.content
342 if document.exitFullscreen
343 document.exitFullscreen()
345 document.mozCancelFullScreen()
347 commands.blur_active_element = ({vim}) ->
348 vim.state.explicitBodyFocus = false
349 utils.blurActiveElement(vim.content)
351 module.exports = commands