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