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