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