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