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