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