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