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