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