]> git.gir.st - VimFx.git/blob - extension/lib/commands-frame.coffee
Remove check for 'close' class for clickable elements
[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.getAttribute('aria-label'), 'close') or
214 # Do this last as it’s a potentially expensive check.
215 utils.hasEventListeners(element, 'click'))
216 # Make a quick check for likely clickable descendants, to reduce the
217 # number of false positives. the element might be a “button-wrapper” or
218 # a large element with a click-tracking event listener.
219 unless element.querySelector('a, button, input, [class*=button]')
220 type = 'clickable'
221 semantic = false
222 # When viewing an image it should get a marker to toggle zoom. This is the
223 # most unlikely rule to match, so keep it last.
224 when document.body?.childElementCount == 1 and
225 element.nodeName == 'IMG' and
226 (element.classList.contains('overflowing') or
227 element.classList.contains('shrinkToFit'))
228 type = 'clickable'
229 type = null if isXUL and element.classList.contains('textbox-input')
230 return {type, semantic}
231 )
232
233 commands.follow_in_tab = helper_follow.bind(null, {id: 'tab'},
234 ({element}) ->
235 type = if isProperLink(element) then 'link' else null
236 return {type, semantic: true}
237 )
238
239 commands.follow_copy = helper_follow.bind(null, {id: 'copy'},
240 ({element}) ->
241 type = switch
242 when isProperLink(element) then 'link'
243 when isContentEditable(element) then 'contenteditable'
244 when isTypingElement(element) then 'text'
245 else null
246 return {type, semantic: true}
247 )
248
249 commands.follow_focus = helper_follow.bind(null, {id: 'focus', combine: false},
250 ({vim, element}) ->
251 type = switch
252 when element.tabIndex > -1
253 'focusable'
254 when element != vim.state.scrollableElements.largest and
255 vim.state.scrollableElements.has(element)
256 'scrollable'
257 else
258 null
259 return {type, semantic: true}
260 )
261
262 commands.focus_marker_element = ({vim, elementIndex, options}) ->
263 {element} = vim.state.markerElements[elementIndex]
264 # To be able to focus scrollable elements, `FLAG_BYKEY` _has_ to be used.
265 options.flag = 'FLAG_BYKEY' if vim.state.scrollableElements.has(element)
266 utils.focusElement(element, options)
267 vim.clearHover()
268 vim.setHover(element)
269
270 commands.click_marker_element = (args) ->
271 {vim, elementIndex, type, preventTargetBlank} = args
272 {element} = vim.state.markerElements[elementIndex]
273 if element.target == '_blank' and preventTargetBlank
274 targetReset = element.target
275 element.target = ''
276 if type == 'clickable-special'
277 element.click()
278 else
279 utils.simulateMouseEvents(element, 'click')
280 element.target = targetReset if targetReset
281
282 commands.copy_marker_element = ({vim, elementIndex, property}) ->
283 {element} = vim.state.markerElements[elementIndex]
284 utils.writeToClipboard(element[property])
285
286 commands.follow_pattern = ({vim, type, options}) ->
287 {document} = vim.content
288
289 # If there’s a `<link rel=prev/next>` element we use that.
290 for link in document.head?.getElementsByTagName('link')
291 # Also support `rel=previous`, just like Google.
292 if type == link.rel.toLowerCase().replace(/^previous$/, 'prev')
293 vim.content.location.href = link.href
294 return
295
296 # Otherwise we look for a link or button on the page that seems to go to the
297 # previous or next page.
298 candidates = document.querySelectorAll(options.pattern_selector)
299
300 # Note: Earlier patterns should be favored.
301 {patterns} = options
302
303 # Search for the prev/next patterns in the following attributes of the
304 # element. `rel` should be kept as the first attribute, since the standard way
305 # of marking up prev/next links (`rel="prev"` and `rel="next"`) should be
306 # favored. Even though some of these attributes only allow a fixed set of
307 # keywords, we pattern-match them anyways since lots of sites don’t follow the
308 # spec and use the attributes arbitrarily.
309 attrs = options.pattern_attrs
310
311 matchingLink = do ->
312 # First search in attributes (favoring earlier attributes) as it's likely
313 # that they are more specific than text contexts.
314 for attr in attrs
315 for regex in patterns
316 for element in candidates
317 return element if regex.test(element.getAttribute(attr))
318
319 # Then search in element contents.
320 for regex in patterns
321 for element in candidates
322 return element if regex.test(element.textContent)
323
324 return null
325
326 if matchingLink
327 utils.simulateMouseEvents(matchingLink, 'click')
328 else
329 vim.notify(translate("notification.follow_#{type}.none"))
330
331 commands.focus_text_input = ({vim, count = null}) ->
332 {lastFocusedTextInput} = vim.state
333 candidates = utils.querySelectorAllDeep(
334 vim.content, 'input, textarea, [contenteditable]'
335 )
336 inputs = Array.filter(candidates, (element) ->
337 return isTextInputElement(element) and utils.area(element) > 0
338 )
339 if lastFocusedTextInput and lastFocusedTextInput not in inputs
340 inputs.push(lastFocusedTextInput)
341 inputs.sort((a, b) -> a.tabIndex - b.tabIndex)
342
343 if inputs.length == 0
344 vim.notify(translate('notification.focus_text_input.none'))
345 return
346
347 num = switch
348 when count?
349 count
350 when lastFocusedTextInput
351 inputs.indexOf(lastFocusedTextInput) + 1
352 else
353 1
354 index = Math.min(num, inputs.length) - 1
355 select = (count? or not vim.state.hasFocusedTextInput)
356 utils.focusElement(inputs[index], {select})
357 vim.state.inputs = inputs
358
359 commands.clear_inputs = ({vim}) ->
360 vim.state.inputs = null
361
362 commands.move_focus = ({vim, direction}) ->
363 return false unless vim.state.inputs
364 index = vim.state.inputs.indexOf(utils.getActiveElement(vim.content))
365 # If there’s only one input, `<tab>` would cycle to itself, making it feel
366 # like `<tab>` was not working. Then it’s better to let `<tab>` work as it
367 # usually does.
368 if index == -1 or vim.state.inputs.length <= 1
369 vim.state.inputs = null
370 return false
371 else
372 {inputs} = vim.state
373 nextInput = inputs[(index + direction) %% inputs.length]
374 utils.focusElement(nextInput, {select: true})
375 return true
376
377 commands.esc = (args) ->
378 {vim} = args
379 commands.blur_active_element(args)
380 vim.clearHover()
381
382 {document} = vim.content
383 if document.exitFullscreen
384 document.exitFullscreen()
385 else
386 document.mozCancelFullScreen()
387
388 commands.blur_active_element = ({vim}) ->
389 vim.state.explicitBodyFocus = false
390 utils.blurActiveElement(vim.content)
391
392 module.exports = commands
Imprint / Impressum