]> git.gir.st - VimFx.git/blob - extension/lib/commands-frame.coffee
Improve click simulation of XUL 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 markableElements = require('./markable-elements')
26 prefs = require('./prefs')
27 SelectionManager = require('./selection')
28 translate = require('./translate')
29 utils = require('./utils')
30 viewportUtils = require('./viewport')
31
32 {FORWARD, BACKWARD} = SelectionManager
33 {isProperLink, isTextInputElement, isTypingElement, isContentEditable} = utils
34
35 XULDocument = Ci.nsIDOMXULDocument
36
37 # <http://www.w3.org/html/wg/drafts/html/master/dom.html#wai-aria>
38 CLICKABLE_ARIA_ROLES = [
39 'link', 'button', 'tab'
40 'checkbox', 'radio', 'combobox', 'option', 'slider', 'textbox'
41 'menuitem', 'menuitemcheckbox', 'menuitemradio'
42 ]
43
44 createComplementarySelectors = (selectors) ->
45 return [
46 selectors.join(',')
47 selectors.map((selector) -> ":not(#{selector})").join('')
48 ]
49
50 FOLLOW_DEFAULT_SELECTORS = createComplementarySelectors([
51 'a', 'button', 'input', 'textarea', 'select'
52 '[role]', '[contenteditable]'
53 ])
54
55 FOLLOW_SELECTABLE_SELECTORS =
56 createComplementarySelectors(['div', 'span']).reverse()
57
58 commands = {}
59
60 commands.go_up_path = ({vim, count = 1}) ->
61 {pathname} = vim.content.location
62 newPathname = pathname.replace(/// (?: /[^/]+ ){1,#{count}} /?$ ///, '')
63 if newPathname == pathname
64 vim.notify(translate('notification.go_up_path.limit'))
65 else
66 vim.content.location.pathname = newPathname
67
68 commands.go_to_root = ({vim}) ->
69 # `.origin` is `'null'` (as a string) on `about:` pages.
70 if "#{vim.content.location.origin}/" in [vim.content.location.href, 'null/']
71 vim.notify(translate('notification.go_up_path.limit'))
72 else
73 vim.content.location.href = vim.content.location.origin
74
75 commands.scroll = (args) ->
76 {vim} = args
77 return unless activeElement = utils.getActiveElement(vim.content)
78 element =
79 # If no element is focused on the page, the active element is the topmost
80 # `<body>`, and blurring it is a no-op. If it is scrollable, it means that
81 # you can’t blur it in order to scroll `<html>`. Therefore it may only be
82 # scrolled if it has been explicitly focused.
83 if vim.state.scrollableElements.has(activeElement) and
84 (activeElement != vim.content.document.body or
85 vim.state.explicitBodyFocus)
86 activeElement
87 else
88 vim.state.scrollableElements.filterSuitableDefault()
89 viewportUtils.scroll(element, args)
90
91 commands.mark_scroll_position = ({vim, keyStr, notify = true}) ->
92 element = vim.state.scrollableElements.filterSuitableDefault()
93 vim.state.marks[keyStr] = [element.scrollTop, element.scrollLeft]
94 if notify
95 vim.notify(translate('notification.mark_scroll_position.success', keyStr))
96
97 commands.scroll_to_mark = (args) ->
98 {vim, amounts: keyStr} = args
99 unless keyStr of vim.state.marks
100 vim.notify(translate('notification.scroll_to_mark.none', keyStr))
101 return
102
103 args.amounts = vim.state.marks[keyStr]
104 element = vim.state.scrollableElements.filterSuitableDefault()
105 viewportUtils.scroll(element, args)
106
107 helper_follow = (options, matcher, {vim, pass}) ->
108 {id, combine = true, selectors = FOLLOW_DEFAULT_SELECTORS} = options
109 if vim.content.document instanceof XULDocument
110 selectors = ['*']
111
112 if pass == 'auto'
113 pass = if selectors.length == 2 then 'first' else 'single'
114
115 vim.state.markerElements = [] if pass in ['single', 'first']
116 hrefs = {}
117
118 filter = (element, getElementShape) ->
119 type = matcher({vim, element, getElementShape})
120 if vim.hintMatcher
121 type = vim.hintMatcher(id, element, type)
122 if pass == 'complementary'
123 type = if type then null else 'complementary'
124 return unless type
125
126 shape = getElementShape(element)
127
128 # CodeMirror editor uses a tiny hidden textarea positioned at the caret.
129 # Targeting those are the only reliable way of focusing CodeMirror editors,
130 # and doing so without moving the caret.
131 if not shape.nonCoveredPoint and id == 'normal' and
132 element.nodeName == 'TEXTAREA' and element.ownerGlobal == vim.content
133 rect = element.getBoundingClientRect()
134 # Use `.clientWidth` instead of `rect.width` because the latter includes
135 # the width of the borders of the textarea, which are unreliable.
136 if element.clientWidth == 1 and rect.height > 0
137 shape = {
138 nonCoveredPoint: {
139 x: rect.left
140 y: rect.top + rect.height / 2
141 offset: {left: 0, top: 0}
142 }
143 area: rect.width * rect.height
144 }
145
146 if not shape.nonCoveredPoint and pass == 'complementary'
147 shape = getElementShape(element, -1)
148
149 return unless shape.nonCoveredPoint
150
151 originalRect = element.getBoundingClientRect()
152 length = vim.state.markerElements.push({element, originalRect})
153 wrapper = {type, shape, elementIndex: length - 1}
154
155 if wrapper.type == 'link'
156 {href} = element
157 wrapper.href = href
158
159 # Combine links with the same href.
160 if combine and wrapper.type == 'link' and
161 # If the element has an 'onclick' attribute we cannot be sure that all
162 # links with this href actually do the same thing. On some pages, such
163 # as startpage.com, actual proper links have the 'onclick' attribute,
164 # so we can’t exclude such links in `utils.isProperLink`.
165 not element.hasAttribute?('onclick') and
166 # GitHub’s diff expansion buttons are links with both `href` and
167 # `data-url`. They are JavaScript-powered using the latter attribute.
168 not element.hasAttribute?('data-url')
169 if href of hrefs
170 parent = hrefs[href]
171 wrapper.parentIndex = parent.elementIndex
172 parent.shape.area += wrapper.shape.area
173 parent.numChildren += 1
174 else
175 wrapper.numChildren = 0
176 hrefs[href] = wrapper
177
178 return wrapper
179
180 selector =
181 if pass == 'complementary'
182 '*'
183 else
184 selectors[if pass == 'second' then 1 else 0]
185 return {
186 wrappers: markableElements.find(vim.content, filter, selector)
187 viewport: viewportUtils.getWindowViewport(vim.content)
188 pass
189 }
190
191 commands.follow = helper_follow.bind(
192 null, {id: 'normal'},
193 ({vim, element, getElementShape}) ->
194 document = element.ownerDocument
195 isXUL = (document instanceof XULDocument)
196 type = null
197 switch
198 # Bootstrap. Match these before regular links, because especially slider
199 # “buttons” often get the same hint otherwise.
200 when element.hasAttribute?('data-toggle') or
201 element.hasAttribute?('data-dismiss') or
202 element.hasAttribute?('data-slide') or
203 element.hasAttribute?('data-slide-to')
204 type = 'clickable'
205 when isProperLink(element)
206 type = 'link'
207 when isTypingElement(element)
208 type = 'text'
209 when element.localName in ['a', 'button'] or
210 element.getAttribute?('role') in CLICKABLE_ARIA_ROLES or
211 # <http://www.w3.org/TR/wai-aria/states_and_properties>
212 element.hasAttribute?('aria-controls') or
213 element.hasAttribute?('aria-pressed') or
214 element.hasAttribute?('aria-checked') or
215 (element.hasAttribute?('aria-haspopup') and
216 element.getAttribute?('role') != 'menu')
217 type = 'clickable'
218 when utils.isFocusable(element) and
219 # Google Drive Documents. The hint for this element would cover the
220 # real hint that allows you to focus the document to start typing.
221 element.id != 'docs-editor'
222 type = 'clickable'
223 when element != vim.state.scrollableElements.largest and
224 vim.state.scrollableElements.has(element)
225 type = 'scrollable'
226 when element.hasAttribute?('onclick') or
227 element.hasAttribute?('onmousedown') or
228 element.hasAttribute?('onmouseup') or
229 element.hasAttribute?('oncommand') or
230 # Twitter.
231 element.classList?.contains('js-new-tweets-bar') or
232 element.hasAttribute?('data-permalink-path') or
233 # Feedly.
234 element.hasAttribute?('data-app-action') or
235 element.hasAttribute?('data-uri') or
236 element.hasAttribute?('data-page-action') or
237 # Google Drive Document.
238 element.classList?.contains('kix-appview-editor')
239 type = 'clickable'
240 # Facebook comment fields.
241 when element.parentElement?.classList?.contains('UFIInputContainer')
242 type = 'clickable-special'
243 # Putting markers on `<label>` elements is generally redundant, because
244 # its `<input>` gets one. However, some sites hide the actual `<input>`
245 # but keeps the `<label>` to click, either for styling purposes or to keep
246 # the `<input>` hidden until it is used. In those cases we should add a
247 # marker for the `<label>`.
248 when element.localName == 'label'
249 input =
250 if element.htmlFor
251 document.getElementById?(element.htmlFor)
252 else
253 element.querySelector?('input, textarea, select')
254 if input and not getElementShape(input).nonCoveredPoint
255 type = 'clickable'
256 # Last resort checks for elements that might be clickable because of
257 # JavaScript.
258 when (not isXUL and
259 # It is common to listen for clicks on `<html>` or `<body>`. Don’t
260 # waste time on them.
261 element not in [document.documentElement, document.body]) and
262 (utils.includes(element.className, 'button') or
263 utils.includes(element.getAttribute?('aria-label'), 'close') or
264 # Do this last as it’s a potentially expensive check.
265 (utils.hasEventListeners(element, 'click') and
266 # Twitter. The hint for this element would cover the hint for
267 # showing more tweets.
268 not element.classList?.contains('js-new-items-bar-container')))
269 # Make a quick check for likely clickable descendants, to reduce the
270 # number of false positives. the element might be a “button-wrapper” or
271 # a large element with a click-tracking event listener.
272 unless element.querySelector?('a, button, input, [class*=button]')
273 type = 'clickable'
274 # When viewing an image it should get a marker to toggle zoom. This is the
275 # most unlikely rule to match, so keep it last.
276 when document.body?.childElementCount == 1 and
277 element.localName == 'img' and
278 (element.classList?.contains('overflowing') or
279 element.classList?.contains('shrinkToFit'))
280 type = 'clickable'
281 type = null if isXUL and element.classList?.contains('textbox-input')
282 return type
283 )
284
285 commands.follow_in_tab = helper_follow.bind(
286 null, {id: 'tab', selectors: ['a']},
287 ({element}) ->
288 type = if isProperLink(element) then 'link' else null
289 return type
290 )
291
292 commands.follow_copy = helper_follow.bind(
293 null, {id: 'copy'},
294 ({element}) ->
295 type = switch
296 when isProperLink(element)
297 'link'
298 when isContentEditable(element)
299 'contenteditable'
300 when isTypingElement(element)
301 'text'
302 else
303 null
304 return type
305 )
306
307 commands.follow_focus = helper_follow.bind(
308 null, {id: 'focus', combine: false},
309 ({vim, element}) ->
310 type = switch
311 when element.tabIndex > -1
312 'focusable'
313 when element != vim.state.scrollableElements.largest and
314 vim.state.scrollableElements.has(element)
315 'scrollable'
316 else
317 null
318 return type
319 )
320
321 commands.follow_selectable = helper_follow.bind(
322 null, {id: 'selectable', selectors: FOLLOW_SELECTABLE_SELECTORS},
323 ({element}) ->
324 isRelevantTextNode = (node) ->
325 # Ignore whitespace-only text nodes, and single-letter ones (which are
326 # common in many syntax highlighters).
327 return node.nodeType == 3 and node.data.trim().length > 1
328 type =
329 if Array.some(element.childNodes, isRelevantTextNode)
330 'selectable'
331 else
332 null
333 return type
334 )
335
336 commands.focus_marker_element = ({vim, elementIndex, options}) ->
337 {element} = vim.state.markerElements[elementIndex]
338 # To be able to focus scrollable elements, `FLAG_BYKEY` _has_ to be used.
339 options.flag = 'FLAG_BYKEY' if vim.state.scrollableElements.has(element)
340 utils.focusElement(element, options)
341 vim.clearHover()
342 vim.setHover(element)
343
344 commands.click_marker_element = (
345 {vim, elementIndex, type, preventTargetBlank}
346 ) ->
347 {element} = vim.state.markerElements[elementIndex]
348 if element.target == '_blank' and preventTargetBlank
349 targetReset = element.target
350 element.target = ''
351 if type == 'clickable-special'
352 element.click()
353 else
354 isXUL = (element.ownerDocument instanceof XULDocument)
355 sequence =
356 if isXUL
357 if element.localName == 'tab' then ['mousedown'] else 'click-xul'
358 else
359 'click'
360 utils.simulateMouseEvents(element, sequence)
361 utils.openDropdown(element)
362 element.target = targetReset if targetReset
363
364 commands.copy_marker_element = ({vim, elementIndex, property}) ->
365 {element} = vim.state.markerElements[elementIndex]
366 utils.writeToClipboard(element[property])
367
368 commands.element_text_select = ({vim, elementIndex, full, scroll = false}) ->
369 {element} = vim.state.markerElements[elementIndex]
370 window = element.ownerGlobal
371 selection = window.getSelection()
372 range = window.document.createRange()
373
374 # Try to scroll the element into view, but keep the caret visible.
375 if scroll
376 viewport = viewportUtils.getWindowViewport(window)
377 rect = element.getBoundingClientRect()
378 block = switch
379 when rect.bottom > viewport.bottom
380 'end'
381 when rect.top < viewport.top and rect.height < viewport.height
382 'start'
383 else
384 null
385 if block
386 smooth = (
387 prefs.root.get('general.smoothScroll') and
388 prefs.root.get('general.smoothScroll.other')
389 )
390 element.scrollIntoView({
391 block
392 behavior: if smooth then 'smooth' else 'instant'
393 })
394
395 if full
396 range.selectNodeContents(element)
397 else
398 result = viewportUtils.getFirstNonWhitespace(element)
399 if result
400 [node, offset] = result
401 range.setStart(node, offset)
402 range.setEnd(node, offset)
403 else
404 range.setStartBefore(element)
405 range.setEndBefore(element)
406
407 utils.clearSelectionDeep(vim.content)
408 window.focus()
409 selection.addRange(range)
410
411 commands.follow_pattern = ({vim, type, options}) ->
412 {document} = vim.content
413
414 # If there’s a `<link rel=prev/next>` element we use that.
415 for link in document.head?.getElementsByTagName('link')
416 # Also support `rel=previous`, just like Google.
417 if type == link.rel.toLowerCase().replace(/^previous$/, 'prev')
418 vim.content.location.href = link.href
419 return
420
421 # Otherwise we look for a link or button on the page that seems to go to the
422 # previous or next page.
423 candidates = document.querySelectorAll(options.pattern_selector)
424
425 # Note: Earlier patterns should be favored.
426 {patterns} = options
427
428 # Search for the prev/next patterns in the following attributes of the
429 # element. `rel` should be kept as the first attribute, since the standard way
430 # of marking up prev/next links (`rel="prev"` and `rel="next"`) should be
431 # favored. Even though some of these attributes only allow a fixed set of
432 # keywords, we pattern-match them anyways since lots of sites don’t follow the
433 # spec and use the attributes arbitrarily.
434 attrs = options.pattern_attrs
435
436 matchingLink = do ->
437 # First search in attributes (favoring earlier attributes) as it's likely
438 # that they are more specific than text contexts.
439 for attr in attrs
440 for regex in patterns
441 for element in candidates
442 return element if regex.test(element.getAttribute?(attr))
443
444 # Then search in element contents.
445 for regex in patterns
446 for element in candidates
447 return element if regex.test(element.textContent)
448
449 return null
450
451 if matchingLink
452 utils.simulateMouseEvents(matchingLink, 'click')
453 # When you go to the next page of GitHub’s code search results, the page is
454 # loaded with AJAX. GitHub then annoyingly focuses its search input. This
455 # autofocus cannot be prevented in a reliable way, because the case is
456 # indistinguishable from a button whose job is to focus some text input.
457 # However, in this command we know for sure that we can prevent the next
458 # focus. This must be done _after_ the click has been triggered, since
459 # clicks count as page interactions.
460 vim.markPageInteraction(false)
461 else
462 vim.notify(translate("notification.follow_#{type}.none"))
463
464 commands.focus_text_input = ({vim, count = null}) ->
465 {lastFocusedTextInput} = vim.state
466
467 if lastFocusedTextInput and utils.isDetached(lastFocusedTextInput)
468 lastFocusedTextInput = null
469
470 candidates = utils.querySelectorAllDeep(
471 vim.content, 'input, textarea, textbox, [contenteditable]'
472 )
473 inputs = Array.filter(candidates, (element) ->
474 return isTextInputElement(element) and utils.area(element) > 0
475 )
476 if lastFocusedTextInput and lastFocusedTextInput not in inputs
477 inputs.push(lastFocusedTextInput)
478 inputs.sort((a, b) -> a.tabIndex - b.tabIndex)
479
480 if inputs.length == 0
481 vim.notify(translate('notification.focus_text_input.none'))
482 return
483
484 num = switch
485 when count?
486 count
487 when lastFocusedTextInput
488 inputs.indexOf(lastFocusedTextInput) + 1
489 else
490 1
491 index = Math.min(num, inputs.length) - 1
492 select = (count? or not vim.state.hasFocusedTextInput)
493 utils.focusElement(inputs[index], {select})
494 vim.state.inputs = inputs
495
496 commands.clear_inputs = ({vim}) ->
497 vim.state.inputs = null
498
499 commands.move_focus = ({vim, direction}) ->
500 return false unless vim.state.inputs
501 index = vim.state.inputs.indexOf(utils.getActiveElement(vim.content))
502 # If there’s only one input, `<tab>` would cycle to itself, making it feel
503 # like `<tab>` was not working. Then it’s better to let `<tab>` work as it
504 # usually does.
505 if index == -1 or vim.state.inputs.length <= 1
506 vim.state.inputs = null
507 return false
508 else
509 {inputs} = vim.state
510 nextInput = inputs[(index + direction) %% inputs.length]
511 utils.focusElement(nextInput, {select: true})
512 return true
513
514 # This is an attempt to enhance Firefox’s native “Find in page” functionality.
515 # Firefox starts searching after the end of the first selection range, or from
516 # the top of the page if there are no selection ranges. If there are frames, the
517 # top-most document in DOM order with selections seems to be used.
518 #
519 # Replace the current selection with one single range. (Searching clears the
520 # previous selection anyway.) That single range is either the first visible
521 # range, or a newly created (and collapsed) one at the top of the viewport. This
522 # way we can control where Firefox searches from.
523 commands.find_from_top_of_viewport = ({vim, direction}) ->
524 viewport = viewportUtils.getWindowViewport(vim.content)
525
526 range = viewportUtils.getFirstVisibleRange(vim.content, viewport)
527 if range
528 window = range.startContainer.ownerGlobal
529 selection = window.getSelection()
530 utils.clearSelectionDeep(vim.content)
531 window.focus()
532 # When the next match is in another frame than the current selection (A),
533 # Firefox won’t clear that selection before making a match selection (B) in
534 # the other frame. When searching again, selection B is cleared because
535 # selection A appears further up the viewport. This causes us to search
536 # _again_ from selection A, rather than selection B. In effect, we get stuck
537 # re-selecting selection B over and over. Therefore, collapse the range
538 # first, in case Firefox doesn’t.
539 range.collapse()
540 selection.addRange(range)
541 # Collapsing the range causes backwards search to keep re-selecting the same
542 # match. Therefore, move it one character back.
543 selection.modify('move', 'backward', 'character') if direction == BACKWARD
544 return
545
546 result = viewportUtils.getFirstVisibleText(vim.content, viewport)
547 return unless result
548 [textNode, offset] = result
549
550 utils.clearSelectionDeep(vim.content)
551 window = textNode.ownerGlobal
552 window.focus()
553 range = window.document.createRange()
554 range.setStart(textNode, offset)
555 range.setEnd(textNode, offset)
556 selection = window.getSelection()
557 selection.addRange(range)
558
559 commands.esc = (args) ->
560 {vim} = args
561 commands.blur_active_element(args)
562 vim.clearHover()
563 utils.clearSelectionDeep(vim.content)
564
565 {document} = vim.content
566 if document.exitFullscreen
567 document.exitFullscreen()
568 else
569 document.mozCancelFullScreen()
570
571 commands.blur_active_element = ({vim}) ->
572 vim.state.explicitBodyFocus = false
573 utils.blurActiveElement(vim.content)
574
575 helper_create_selection_manager = (vim) ->
576 window = utils.getActiveElement(vim.content)?.ownerGlobal ? vim.content
577 return new SelectionManager(window)
578
579 commands.enable_caret = ({vim}) ->
580 return unless selectionManager = helper_create_selection_manager(vim)
581 selectionManager.enableCaret()
582
583 commands.move_caret = ({vim, method, direction, select, count}) ->
584 return unless selectionManager = helper_create_selection_manager(vim)
585 for [0...count] by 1
586 if selectionManager[method]
587 error = selectionManager[method](direction, select)
588 else
589 error = selectionManager.moveCaret(method, direction, select)
590 break if error
591 return
592
593 commands.toggle_selection = ({vim, select}) ->
594 return unless selectionManager = helper_create_selection_manager(vim)
595 if select
596 vim.notify(translate('notification.toggle_selection.enter'))
597 else
598 selectionManager.collapse()
599
600 commands.toggle_selection_direction = ({vim}) ->
601 return unless selectionManager = helper_create_selection_manager(vim)
602 selectionManager.reverseDirection()
603
604 commands.get_selection = ({vim}) ->
605 return unless selectionManager = helper_create_selection_manager(vim)
606 return selectionManager.selection.toString()
607
608 commands.collapse_selection = ({vim}) ->
609 return unless selectionManager = helper_create_selection_manager(vim)
610 selectionManager.collapse()
611
612 commands.clear_selection = ({vim}) ->
613 utils.clearSelectionDeep(vim.content)
614
615 module.exports = commands
Imprint / Impressum