]> git.gir.st - VimFx.git/blob - extension/lib/commands-frame.coffee
Recognize more elements as clickable
[VimFx.git] / extension / lib / commands-frame.coffee
1 ###
2 # Copyright Simon Lydell 2015, 2016.
3 #
4 # This file is part of VimFx.
5 #
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.
10 #
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.
15 #
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/>.
18 ###
19
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.
24
25 hints = require('./hints')
26 translate = require('./l10n')
27 utils = require('./utils')
28
29 {isProperLink, isTextInputElement, isTypingElement, isContentEditable} = utils
30
31 XULDocument = Ci.nsIDOMXULDocument
32
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'
38 ]
39
40 commands = {}
41
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'))
47 else
48 vim.content.location.pathname = newPathname
49
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'))
54 else
55 vim.content.location.href = vim.content.location.origin
56
57 commands.scroll = (args) ->
58 {vim} = args
59 return unless activeElement = utils.getActiveElement(vim.content)
60 element =
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)
68 activeElement
69 else
70 vim.state.scrollableElements.filterSuitableDefault()
71 utils.scroll(element, args)
72
73 commands.mark_scroll_position = ({vim, keyStr, notify = true}) ->
74 element = vim.state.scrollableElements.filterSuitableDefault()
75 vim.state.marks[keyStr] = [element.scrollTop, element.scrollLeft]
76 if notify
77 vim.notify(translate('notification.mark_scroll_position.success', keyStr))
78
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))
83 return
84
85 args.amounts = vim.state.marks[keyStr]
86 element = vim.state.scrollableElements.filterSuitableDefault()
87 utils.scroll(element, args)
88
89 helper_follow = ({id, combine = true}, matcher, {vim}) ->
90 hrefs = {}
91 vim.state.markerElements = []
92
93 filter = (element, getElementShape) ->
94 {type, semantic} = matcher({vim, element, getElementShape})
95
96 customMatcher = FRAME_SCRIPT_ENVIRONMENT.VimFxHintMatcher
97 if customMatcher
98 {type, semantic} = customMatcher(id, element, {type, semantic})
99
100 return unless type
101 return unless shape = getElementShape(element)
102
103 originalRect = element.getBoundingClientRect()
104 length = vim.state.markerElements.push({element, originalRect})
105 wrapper = {type, semantic, shape, elementIndex: length - 1}
106
107 if wrapper.type == 'link'
108 {href} = element
109 wrapper.href = href
110
111 # Combine links with the same href.
112 if combine and wrapper.type == 'link' and
113 # If the element has an 'onclick' attribute we cannot be sure that all
114 # links with this href actually do the same thing. On some pages, such
115 # as startpage.com, actual proper links have the 'onclick' attribute,
116 # so we can’t exclude such links in `utils.isProperLink`.
117 not element.hasAttribute('onclick') and
118 # GitHub’s diff expansion buttons are links with both `href` and
119 # `data-url`. They are JavaScript-powered using the latter attribute.
120 not element.hasAttribute('data-url')
121 if href of hrefs
122 parent = hrefs[href]
123 wrapper.parentIndex = parent.elementIndex
124 parent.shape.area += wrapper.shape.area
125 parent.numChildren++
126 else
127 wrapper.numChildren = 0
128 hrefs[href] = wrapper
129
130 return wrapper
131
132 return hints.getMarkableElementsAndViewport(vim.content, filter)
133
134 commands.follow = helper_follow.bind(null, {id: 'normal'},
135 ({vim, element, getElementShape}) ->
136 document = element.ownerDocument
137 isXUL = (document instanceof XULDocument)
138 type = null
139 semantic = true
140 switch
141 # Bootstrap. Match these before regular links, because especially slider
142 # “buttons” often get the same hint otherwise.
143 when element.hasAttribute('data-toggle') or
144 element.hasAttribute('data-dismiss') or
145 element.hasAttribute('data-slide') or
146 element.hasAttribute('data-slide-to')
147 # Some elements may not be semantic, but _should be_ and still deserve a
148 # good hint.
149 type = 'clickable'
150 when isProperLink(element)
151 type = 'link'
152 when isTypingElement(element)
153 type = 'text'
154 when element.getAttribute('role') in CLICKABLE_ARIA_ROLES or
155 # <http://www.w3.org/TR/wai-aria/states_and_properties>
156 element.hasAttribute('aria-controls') or
157 element.hasAttribute('aria-pressed') or
158 element.hasAttribute('aria-checked') or
159 (element.hasAttribute('aria-haspopup') and
160 element.getAttribute('role') != 'menu')
161 type = 'clickable'
162 when element.tabIndex > -1 and
163 # Google Drive Documents. The hint for this element would cover the
164 # real hint that allows you to focus the document to start typing.
165 element.id != 'docs-editor' and
166 not (isXUL and element.nodeName.endsWith('box') and
167 element.nodeName != 'checkbox')
168 type = 'clickable'
169 unless isXUL or element.nodeName in ['A', 'INPUT', 'BUTTON']
170 semantic = false
171 when element != vim.state.scrollableElements.largest and
172 vim.state.scrollableElements.has(element)
173 type = 'scrollable'
174 when element.hasAttribute('onclick') or
175 element.hasAttribute('onmousedown') or
176 element.hasAttribute('onmouseup') or
177 element.hasAttribute('oncommand') or
178 # Twitter.
179 element.classList.contains('js-new-tweets-bar') or
180 # Feedly.
181 element.hasAttribute('data-app-action') or
182 element.hasAttribute('data-uri') or
183 element.hasAttribute('data-page-action') or
184 # CodeMirror.
185 element.classList.contains('CodeMirror-scroll') or
186 # Google Drive Document.
187 element.classList.contains('kix-appview-editor')
188 type = 'clickable'
189 semantic = false
190 # Facebook comment fields.
191 when element.parentElement?.classList.contains('UFIInputContainer')
192 type = 'clickable-special'
193 # Putting markers on `<label>` elements is generally redundant, because
194 # its `<input>` gets one. However, some sites hide the actual `<input>`
195 # but keeps the `<label>` to click, either for styling purposes or to keep
196 # the `<input>` hidden until it is used. In those cases we should add a
197 # marker for the `<label>`.
198 when element.nodeName == 'LABEL'
199 input =
200 if element.htmlFor
201 document.getElementById(element.htmlFor)
202 else
203 element.querySelector('input, textarea, select')
204 if input and not getElementShape(input)
205 type = 'clickable'
206 # Last resort checks for elements that might be clickable because of
207 # JavaScript.
208 when (not isXUL and
209 # It is common to listen for clicks on `<html>` or `<body>`. Don’t
210 # waste time on them.
211 element not in [document.documentElement, document.body]) and
212 (utils.includes(element.className, 'button') or
213 utils.includes(element.className, 'close') or
214 utils.includes(element.getAttribute('aria-label'), 'close') or
215 # Do this last as it’s a potentially expensive check.
216 utils.hasEventListeners(element, 'click'))
217 # Make a quick check for likely clickable descendants, to reduce the
218 # number of false positives. the element might be a “button-wrapper” or
219 # a large element with a click-tracking event listener.
220 unless element.querySelector('a, button, input, [class*=button]')
221 type = 'clickable'
222 semantic = false
223 # When viewing an image it should get a marker to toggle zoom. This is the
224 # most unlikely rule to match, so keep it last.
225 when document.body?.childElementCount == 1 and
226 element.nodeName == 'IMG' and
227 (element.classList.contains('overflowing') or
228 element.classList.contains('shrinkToFit'))
229 type = 'clickable'
230 type = null if isXUL and element.classList.contains('textbox-input')
231 return {type, semantic}
232 )
233
234 commands.follow_in_tab = helper_follow.bind(null, {id: 'tab'},
235 ({element}) ->
236 type = if isProperLink(element) then 'link' else null
237 return {type, semantic: true}
238 )
239
240 commands.follow_copy = helper_follow.bind(null, {id: 'copy'},
241 ({element}) ->
242 type = switch
243 when isProperLink(element) then 'link'
244 when isContentEditable(element) then 'contenteditable'
245 when isTypingElement(element) then 'text'
246 else null
247 return {type, semantic: true}
248 )
249
250 commands.follow_focus = helper_follow.bind(null, {id: 'focus', combine: false},
251 ({vim, element}) ->
252 type = switch
253 when element.tabIndex > -1
254 'focusable'
255 when element != vim.state.scrollableElements.largest and
256 vim.state.scrollableElements.has(element)
257 'scrollable'
258 else
259 null
260 return {type, semantic: true}
261 )
262
263 commands.focus_marker_element = ({vim, elementIndex, options}) ->
264 {element} = vim.state.markerElements[elementIndex]
265 # To be able to focus scrollable elements, `FLAG_BYKEY` _has_ to be used.
266 options.flag = 'FLAG_BYKEY' if vim.state.scrollableElements.has(element)
267 utils.focusElement(element, options)
268 vim.clearHover()
269 vim.setHover(element)
270
271 commands.click_marker_element = (args) ->
272 {vim, elementIndex, type, preventTargetBlank} = args
273 {element} = vim.state.markerElements[elementIndex]
274 if element.target == '_blank' and preventTargetBlank
275 targetReset = element.target
276 element.target = ''
277 if type == 'clickable-special'
278 element.click()
279 else
280 utils.simulateMouseEvents(element, 'click')
281 element.target = targetReset if targetReset
282
283 commands.copy_marker_element = ({vim, elementIndex, property}) ->
284 {element} = vim.state.markerElements[elementIndex]
285 utils.writeToClipboard(element[property])
286
287 commands.follow_pattern = ({vim, type, options}) ->
288 {document} = vim.content
289
290 # If there’s a `<link rel=prev/next>` element we use that.
291 for link in document.head?.getElementsByTagName('link')
292 # Also support `rel=previous`, just like Google.
293 if type == link.rel.toLowerCase().replace(/^previous$/, 'prev')
294 vim.content.location.href = link.href
295 return
296
297 # Otherwise we look for a link or button on the page that seems to go to the
298 # previous or next page.
299 candidates = document.querySelectorAll(options.pattern_selector)
300
301 # Note: Earlier patterns should be favored.
302 {patterns} = options
303
304 # Search for the prev/next patterns in the following attributes of the
305 # element. `rel` should be kept as the first attribute, since the standard way
306 # of marking up prev/next links (`rel="prev"` and `rel="next"`) should be
307 # favored. Even though some of these attributes only allow a fixed set of
308 # keywords, we pattern-match them anyways since lots of sites don’t follow the
309 # spec and use the attributes arbitrarily.
310 attrs = options.pattern_attrs
311
312 matchingLink = do ->
313 # First search in attributes (favoring earlier attributes) as it's likely
314 # that they are more specific than text contexts.
315 for attr in attrs
316 for regex in patterns
317 for element in candidates
318 return element if regex.test(element.getAttribute(attr))
319
320 # Then search in element contents.
321 for regex in patterns
322 for element in candidates
323 return element if regex.test(element.textContent)
324
325 return null
326
327 if matchingLink
328 utils.simulateMouseEvents(matchingLink, 'click')
329 else
330 vim.notify(translate("notification.follow_#{type}.none"))
331
332 commands.focus_text_input = ({vim, count = null}) ->
333 {lastFocusedTextInput} = vim.state
334 candidates = utils.querySelectorAllDeep(
335 vim.content, 'input, textarea, [contenteditable]'
336 )
337 inputs = Array.filter(candidates, (element) ->
338 return isTextInputElement(element) and utils.area(element) > 0
339 )
340 if lastFocusedTextInput and lastFocusedTextInput not in inputs
341 inputs.push(lastFocusedTextInput)
342 inputs.sort((a, b) -> a.tabIndex - b.tabIndex)
343
344 if inputs.length == 0
345 vim.notify(translate('notification.focus_text_input.none'))
346 return
347
348 num = switch
349 when count?
350 count
351 when lastFocusedTextInput
352 inputs.indexOf(lastFocusedTextInput) + 1
353 else
354 1
355 index = Math.min(num, inputs.length) - 1
356 select = (count? or not vim.state.hasFocusedTextInput)
357 utils.focusElement(inputs[index], {select})
358 vim.state.inputs = inputs
359
360 commands.clear_inputs = ({vim}) ->
361 vim.state.inputs = null
362
363 commands.move_focus = ({vim, direction}) ->
364 return false unless vim.state.inputs
365 index = vim.state.inputs.indexOf(utils.getActiveElement(vim.content))
366 # If there’s only one input, `<tab>` would cycle to itself, making it feel
367 # like `<tab>` was not working. Then it’s better to let `<tab>` work as it
368 # usually does.
369 if index == -1 or vim.state.inputs.length <= 1
370 vim.state.inputs = null
371 return false
372 else
373 {inputs} = vim.state
374 nextInput = inputs[(index + direction) %% inputs.length]
375 utils.focusElement(nextInput, {select: true})
376 return true
377
378 commands.esc = (args) ->
379 {vim} = args
380 commands.blur_active_element(args)
381 vim.clearHover()
382
383 {document} = vim.content
384 if document.exitFullscreen
385 document.exitFullscreen()
386 else
387 document.mozCancelFullScreen()
388
389 commands.blur_active_element = ({vim}) ->
390 vim.state.explicitBodyFocus = false
391 utils.blurActiveElement(vim.content)
392
393 module.exports = commands
Imprint / Impressum