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