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