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