]> git.gir.st - VimFx.git/blob - extension/lib/commands-frame.coffee
Include possibly covered elements for complementary hints
[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('./l10n')
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 the active element is the
80 # topmost `<body>`, and blurring it is a no-op. If it is scrollable, it
81 # means that you can’t blur it in order to scroll `<html>`. Therefore it may
82 # only be 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 element.target = targetReset if targetReset
362
363 commands.copy_marker_element = ({vim, elementIndex, property}) ->
364 {element} = vim.state.markerElements[elementIndex]
365 utils.writeToClipboard(element[property])
366
367 commands.element_text_select = ({vim, elementIndex, full, scroll = false}) ->
368 {element} = vim.state.markerElements[elementIndex]
369 window = element.ownerGlobal
370 selection = window.getSelection()
371 range = window.document.createRange()
372
373 # Try to scroll the element into view, but keep the caret visible.
374 if scroll
375 viewport = viewportUtils.getWindowViewport(window)
376 rect = element.getBoundingClientRect()
377 block = switch
378 when rect.bottom > viewport.bottom
379 'end'
380 when rect.top < viewport.top and rect.height < viewport.height
381 'start'
382 else
383 null
384 if block
385 smooth = (
386 prefs.root.get('general.smoothScroll') and
387 prefs.root.get('general.smoothScroll.other')
388 )
389 element.scrollIntoView({
390 block
391 behavior: if smooth then 'smooth' else 'instant'
392 })
393
394 if full
395 range.selectNodeContents(element)
396 else
397 result = viewportUtils.getFirstNonWhitespace(element)
398 if result
399 [node, offset] = result
400 range.setStart(node, offset)
401 range.setEnd(node, offset)
402 else
403 range.setStartBefore(element)
404 range.setEndBefore(element)
405
406 utils.clearSelectionDeep(vim.content)
407 window.focus()
408 selection.addRange(range)
409
410 commands.follow_pattern = ({vim, type, options}) ->
411 {document} = vim.content
412
413 # If there’s a `<link rel=prev/next>` element we use that.
414 for link in document.head?.getElementsByTagName('link')
415 # Also support `rel=previous`, just like Google.
416 if type == link.rel.toLowerCase().replace(/^previous$/, 'prev')
417 vim.content.location.href = link.href
418 return
419
420 # Otherwise we look for a link or button on the page that seems to go to the
421 # previous or next page.
422 candidates = document.querySelectorAll(options.pattern_selector)
423
424 # Note: Earlier patterns should be favored.
425 {patterns} = options
426
427 # Search for the prev/next patterns in the following attributes of the
428 # element. `rel` should be kept as the first attribute, since the standard way
429 # of marking up prev/next links (`rel="prev"` and `rel="next"`) should be
430 # favored. Even though some of these attributes only allow a fixed set of
431 # keywords, we pattern-match them anyways since lots of sites don’t follow the
432 # spec and use the attributes arbitrarily.
433 attrs = options.pattern_attrs
434
435 matchingLink = do ->
436 # First search in attributes (favoring earlier attributes) as it's likely
437 # that they are more specific than text contexts.
438 for attr in attrs
439 for regex in patterns
440 for element in candidates
441 return element if regex.test(element.getAttribute?(attr))
442
443 # Then search in element contents.
444 for regex in patterns
445 for element in candidates
446 return element if regex.test(element.textContent)
447
448 return null
449
450 if matchingLink
451 utils.simulateMouseEvents(matchingLink, 'click')
452 # When you go to the next page of GitHub’s code search results, the page is
453 # loaded with AJAX. GitHub then annoyingly focuses its search input. This
454 # autofocus cannot be prevented in a reliable way, because the case is
455 # indistinguishable from a button whose job is to focus some text input.
456 # However, in this command we know for sure that we can prevent the next
457 # focus. This must be done _after_ the click has been triggered, since
458 # clicks count as page interactions.
459 vim.markPageInteraction(false)
460 else
461 vim.notify(translate("notification.follow_#{type}.none"))
462
463 commands.focus_text_input = ({vim, count = null}) ->
464 {lastFocusedTextInput} = vim.state
465
466 if lastFocusedTextInput and utils.isDetached(lastFocusedTextInput)
467 lastFocusedTextInput = null
468
469 candidates = utils.querySelectorAllDeep(
470 vim.content, 'input, textarea, textbox, [contenteditable]'
471 )
472 inputs = Array.filter(candidates, (element) ->
473 return isTextInputElement(element) and utils.area(element) > 0
474 )
475 if lastFocusedTextInput and lastFocusedTextInput not in inputs
476 inputs.push(lastFocusedTextInput)
477 inputs.sort((a, b) -> a.tabIndex - b.tabIndex)
478
479 if inputs.length == 0
480 vim.notify(translate('notification.focus_text_input.none'))
481 return
482
483 num = switch
484 when count?
485 count
486 when lastFocusedTextInput
487 inputs.indexOf(lastFocusedTextInput) + 1
488 else
489 1
490 index = Math.min(num, inputs.length) - 1
491 select = (count? or not vim.state.hasFocusedTextInput)
492 utils.focusElement(inputs[index], {select})
493 vim.state.inputs = inputs
494
495 commands.clear_inputs = ({vim}) ->
496 vim.state.inputs = null
497
498 commands.move_focus = ({vim, direction}) ->
499 return false unless vim.state.inputs
500 index = vim.state.inputs.indexOf(utils.getActiveElement(vim.content))
501 # If there’s only one input, `<tab>` would cycle to itself, making it feel
502 # like `<tab>` was not working. Then it’s better to let `<tab>` work as it
503 # usually does.
504 if index == -1 or vim.state.inputs.length <= 1
505 vim.state.inputs = null
506 return false
507 else
508 {inputs} = vim.state
509 nextInput = inputs[(index + direction) %% inputs.length]
510 utils.focusElement(nextInput, {select: true})
511 return true
512
513 # This is an attempt to enhance Firefox’s native “Find in page” functionality.
514 # Firefox starts searching after the end of the first selection range, or from
515 # the top of the page if there are no selection ranges. If there are frames, the
516 # top-most document in DOM order with selections seems to be used.
517 #
518 # Replace the current selection with one single range. (Searching clears the
519 # previous selection anyway.) That single range is either the first visible
520 # range, or a newly created (and collapsed) one at the top of the viewport. This
521 # way we can control where Firefox searches from.
522 commands.find_from_top_of_viewport = ({vim, direction}) ->
523 viewport = viewportUtils.getWindowViewport(vim.content)
524
525 range = viewportUtils.getFirstVisibleRange(vim.content, viewport)
526 if range
527 window = range.startContainer.ownerGlobal
528 selection = window.getSelection()
529 utils.clearSelectionDeep(vim.content)
530 window.focus()
531 # When the next match is in another frame than the current selection (A),
532 # Firefox won’t clear that selection before making a match selection (B) in
533 # the other frame. When searching again, selection B is cleared because
534 # selection A appears further up the viewport. This causes us to search
535 # _again_ from selection A, rather than selection B. In effect, we get stuck
536 # re-selecting selection B over and over. Therefore, collapse the range
537 # first, in case Firefox doesn’t.
538 range.collapse()
539 selection.addRange(range)
540 # Collapsing the range causes backwards search to keep re-selecting the same
541 # match. Therefore, move it one character back.
542 selection.modify('move', 'backward', 'character') if direction == BACKWARD
543 return
544
545 result = viewportUtils.getFirstVisibleText(vim.content, viewport)
546 return unless result
547 [textNode, offset] = result
548
549 utils.clearSelectionDeep(vim.content)
550 window = textNode.ownerGlobal
551 window.focus()
552 range = window.document.createRange()
553 range.setStart(textNode, offset)
554 range.setEnd(textNode, offset)
555 selection = window.getSelection()
556 selection.addRange(range)
557
558 commands.esc = (args) ->
559 {vim} = args
560 commands.blur_active_element(args)
561 vim.clearHover()
562 utils.clearSelectionDeep(vim.content)
563
564 {document} = vim.content
565 if document.exitFullscreen
566 document.exitFullscreen()
567 else
568 document.mozCancelFullScreen()
569
570 commands.blur_active_element = ({vim}) ->
571 vim.state.explicitBodyFocus = false
572 utils.blurActiveElement(vim.content)
573
574 helper_create_selection_manager = (vim) ->
575 window = utils.getActiveElement(vim.content)?.ownerGlobal ? vim.content
576 return new SelectionManager(window)
577
578 commands.enable_caret = ({vim}) ->
579 return unless selectionManager = helper_create_selection_manager(vim)
580 selectionManager.enableCaret()
581
582 commands.move_caret = ({vim, method, direction, select, count}) ->
583 return unless selectionManager = helper_create_selection_manager(vim)
584 for [0...count] by 1
585 if selectionManager[method]
586 error = selectionManager[method](direction, select)
587 else
588 error = selectionManager.moveCaret(method, direction, select)
589 break if error
590 return
591
592 commands.toggle_selection = ({vim, select}) ->
593 return unless selectionManager = helper_create_selection_manager(vim)
594 if select
595 vim.notify(translate('notification.toggle_selection.enter'))
596 else
597 selectionManager.collapse()
598
599 commands.toggle_selection_direction = ({vim}) ->
600 return unless selectionManager = helper_create_selection_manager(vim)
601 selectionManager.reverseDirection()
602
603 commands.get_selection = ({vim}) ->
604 return unless selectionManager = helper_create_selection_manager(vim)
605 return selectionManager.selection.toString()
606
607 commands.collapse_selection = ({vim}) ->
608 return unless selectionManager = helper_create_selection_manager(vim)
609 selectionManager.collapse()
610
611 commands.clear_selection = ({vim}) ->
612 utils.clearSelectionDeep(vim.content)
613
614 module.exports = commands
Imprint / Impressum