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