]> 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 customMatcher = FRAME_SCRIPT_ENVIRONMENT.VimFxHintMatcher
97 if customMatcher
98 {type, semantic} = customMatcher(id, element, {type, semantic})
99
100 return unless type
101 return unless shape = getElementShape(element)
102
103 originalRect = element.getBoundingClientRect()
104 length = vim.state.markerElements.push({element, originalRect})
105 wrapper = {type, semantic, shape, elementIndex: length - 1}
106
107 if wrapper.type == 'link'
108 {href} = element
109 wrapper.href = href
110
111 # Combine links with the same href.
112 if combine and wrapper.type == 'link' and
113 # If the element has an 'onclick' attribute we cannot be sure that all
114 # links with this href actually do the same thing. On some pages, such
115 # as startpage.com, actual proper links have the 'onclick' attribute,
116 # so we can’t exclude such links in `utils.isProperLink`.
117 not element.hasAttribute('onclick') and
118 # GitHub’s diff expansion buttons are links with both `href` and
119 # `data-url`. They are JavaScript-powered using the latter attribute.
120 not element.hasAttribute('data-url')
121 if href of hrefs
122 parent = hrefs[href]
123 wrapper.parentIndex = parent.elementIndex
124 parent.shape.area += wrapper.shape.area
125 parent.numChildren++
126 else
127 wrapper.numChildren = 0
128 hrefs[href] = wrapper
129
130 return wrapper
131
132 return hints.getMarkableElementsAndViewport(vim.content, filter)
133
134 commands.follow = helper_follow.bind(null, {id: 'normal'},
135 ({vim, element, getElementShape}) ->
136 document = element.ownerDocument
137 isXUL = (document instanceof XULDocument)
138 type = null
139 semantic = true
140 switch
141 # Bootstrap. Match these before regular links, because especially slider
142 # “buttons” often get the same hint otherwise.
143 when element.hasAttribute('data-toggle') or
144 element.hasAttribute('data-dismiss') or
145 element.hasAttribute('data-slide') or
146 element.hasAttribute('data-slide-to')
147 # Some elements may not be semantic, but _should be_ and still deserve a
148 # good hint.
149 type = 'clickable'
150 when isProperLink(element)
151 type = 'link'
152 when isTypingElement(element)
153 type = 'text'
154 when element.getAttribute('role') in CLICKABLE_ARIA_ROLES or
155 # <http://www.w3.org/TR/wai-aria/states_and_properties>
156 element.hasAttribute('aria-controls') or
157 element.hasAttribute('aria-pressed') or
158 element.hasAttribute('aria-checked') or
159 (element.hasAttribute('aria-haspopup') and
160 element.getAttribute('role') != 'menu')
161 type = 'clickable'
162 when element.tabIndex > -1 and
163 # Google Drive Documents. The hint for this element would cover the
164 # real hint that allows you to focus the document to start typing.
165 element.id != 'docs-editor' and
166 not (isXUL and element.nodeName.endsWith('box') and
167 element.nodeName != 'checkbox')
168 type = 'clickable'
169 unless isXUL or element.nodeName in ['A', 'INPUT', 'BUTTON']
170 semantic = false
171 when element != vim.state.scrollableElements.largest and
172 vim.state.scrollableElements.has(element)
173 type = 'scrollable'
174 when element.hasAttribute('onclick') or
175 element.hasAttribute('onmousedown') or
176 element.hasAttribute('onmouseup') or
177 element.hasAttribute('oncommand') or
178 # Twitter.
179 element.classList.contains('js-new-tweets-bar') or
180 # Feedly.
181 element.hasAttribute('data-app-action') or
182 element.hasAttribute('data-uri') or
183 element.hasAttribute('data-page-action') or
184 # CodeMirror.
185 element.classList.contains('CodeMirror-scroll') or
186 # Google Drive Document.
187 element.classList.contains('kix-appview-editor')
188 type = 'clickable'
189 semantic = false
190 # Facebook comment fields.
191 when element.parentElement?.classList.contains('UFIInputContainer')
192 type = 'clickable-special'
193 # Putting markers on `<label>` elements is generally redundant, because
194 # its `<input>` gets one. However, some sites hide the actual `<input>`
195 # but keeps the `<label>` to click, either for styling purposes or to keep
196 # the `<input>` hidden until it is used. In those cases we should add a
197 # marker for the `<label>`.
198 when element.nodeName == 'LABEL'
199 input =
200 if element.htmlFor
201 document.getElementById(element.htmlFor)
202 else
203 element.querySelector('input, textarea, select')
204 if input and not getElementShape(input)
205 type = 'clickable'
206 # Last resort checks for elements that might be clickable because of
207 # JavaScript.
208 when (not isXUL and
209 # It is common to listen for clicks on `<html>` or `<body>`. Don’t
210 # waste time on them.
211 element not in [document.documentElement, document.body]) and
212 (utils.includes(element.className, 'button') or
213 utils.includes(element.getAttribute('aria-label'), 'close') or
214 # Do this last as it’s a potentially expensive check.
215 utils.hasEventListeners(element, 'click'))
216 # Make a quick check for likely clickable descendants, to reduce the
217 # number of false positives. the element might be a “button-wrapper” or
218 # a large element with a click-tracking event listener.
219 unless element.querySelector('a, button, input, [class*=button]')
220 type = 'clickable'
221 semantic = false
222 # When viewing an image it should get a marker to toggle zoom. This is the
223 # most unlikely rule to match, so keep it last.
224 when document.body?.childElementCount == 1 and
225 element.nodeName == 'IMG' and
226 (element.classList.contains('overflowing') or
227 element.classList.contains('shrinkToFit'))
228 type = 'clickable'
229 type = null if isXUL and element.classList.contains('textbox-input')
230 return {type, semantic}
231 )
232
233 commands.follow_in_tab = helper_follow.bind(null, {id: 'tab'},
234 ({element}) ->
235 type = if isProperLink(element) then 'link' else null
236 return {type, semantic: true}
237 )
238
239 commands.follow_copy = helper_follow.bind(null, {id: 'copy'},
240 ({element}) ->
241 type = switch
242 when isProperLink(element) then 'link'
243 when isContentEditable(element) then 'contenteditable'
244 when isTypingElement(element) then 'text'
245 else null
246 return {type, semantic: true}
247 )
248
249 commands.follow_focus = helper_follow.bind(null, {id: 'focus', combine: false},
250 ({vim, element}) ->
251 type = switch
252 when element.tabIndex > -1
253 'focusable'
254 when element != vim.state.scrollableElements.largest and
255 vim.state.scrollableElements.has(element)
256 'scrollable'
257 else
258 null
259 return {type, semantic: true}
260 )
261
262 commands.focus_marker_element = ({vim, elementIndex, options}) ->
263 {element} = vim.state.markerElements[elementIndex]
264 # To be able to focus scrollable elements, `FLAG_BYKEY` _has_ to be used.
265 options.flag = 'FLAG_BYKEY' if vim.state.scrollableElements.has(element)
266 utils.focusElement(element, options)
267 vim.clearHover()
268 vim.setHover(element)
269
270 commands.click_marker_element = (args) ->
271 {vim, elementIndex, type, preventTargetBlank} = args
272 {element} = vim.state.markerElements[elementIndex]
273 if element.target == '_blank' and preventTargetBlank
274 targetReset = element.target
275 element.target = ''
276 if type == 'clickable-special'
277 element.click()
278 else
279 utils.simulateMouseEvents(element, 'click')
280 element.target = targetReset if targetReset
281
282 commands.copy_marker_element = ({vim, elementIndex, property}) ->
283 {element} = vim.state.markerElements[elementIndex]
284 utils.writeToClipboard(element[property])
285
286 commands.follow_pattern = ({vim, type, options}) ->
287 {document} = vim.content
288
289 # If there’s a `<link rel=prev/next>` element we use that.
290 for link in document.head?.getElementsByTagName('link')
291 # Also support `rel=previous`, just like Google.
292 if type == link.rel.toLowerCase().replace(/^previous$/, 'prev')
293 vim.content.location.href = link.href
294 return
295
296 # Otherwise we look for a link or button on the page that seems to go to the
297 # previous or next page.
298 candidates = document.querySelectorAll(options.pattern_selector)
299
300 # Note: Earlier patterns should be favored.
301 {patterns} = options
302
303 # Search for the prev/next patterns in the following attributes of the
304 # element. `rel` should be kept as the first attribute, since the standard way
305 # of marking up prev/next links (`rel="prev"` and `rel="next"`) should be
306 # favored. Even though some of these attributes only allow a fixed set of
307 # keywords, we pattern-match them anyways since lots of sites don’t follow the
308 # spec and use the attributes arbitrarily.
309 attrs = options.pattern_attrs
310
311 matchingLink = do ->
312 # First search in attributes (favoring earlier attributes) as it's likely
313 # that they are more specific than text contexts.
314 for attr in attrs
315 for regex in patterns
316 for element in candidates
317 return element if regex.test(element.getAttribute(attr))
318
319 # Then search in element contents.
320 for regex in patterns
321 for element in candidates
322 return element if regex.test(element.textContent)
323
324 return null
325
326 if matchingLink
327 utils.simulateMouseEvents(matchingLink, 'click')
328 # When you go to the next page of GitHub’s code search results, the page is
329 # loaded with AJAX. GitHub then annoyingly focuses its search input. This
330 # autofocus cannot be prevented in a reliable way, because the case is
331 # indistinguishable from a button whose job is to focus some text input.
332 # However, in this command we know for sure that we can prevent the next
333 # focus. This must be done _after_ the click has been triggered, since
334 # clicks count as page interactions.
335 vim.markPageInteraction(false)
336 else
337 vim.notify(translate("notification.follow_#{type}.none"))
338
339 commands.focus_text_input = ({vim, count = null}) ->
340 {lastFocusedTextInput} = vim.state
341 candidates = utils.querySelectorAllDeep(
342 vim.content, 'input, textarea, [contenteditable]'
343 )
344 inputs = Array.filter(candidates, (element) ->
345 return isTextInputElement(element) and utils.area(element) > 0
346 )
347 if lastFocusedTextInput and lastFocusedTextInput not in inputs
348 inputs.push(lastFocusedTextInput)
349 inputs.sort((a, b) -> a.tabIndex - b.tabIndex)
350
351 if inputs.length == 0
352 vim.notify(translate('notification.focus_text_input.none'))
353 return
354
355 num = switch
356 when count?
357 count
358 when lastFocusedTextInput
359 inputs.indexOf(lastFocusedTextInput) + 1
360 else
361 1
362 index = Math.min(num, inputs.length) - 1
363 select = (count? or not vim.state.hasFocusedTextInput)
364 utils.focusElement(inputs[index], {select})
365 vim.state.inputs = inputs
366
367 commands.clear_inputs = ({vim}) ->
368 vim.state.inputs = null
369
370 commands.move_focus = ({vim, direction}) ->
371 return false unless vim.state.inputs
372 index = vim.state.inputs.indexOf(utils.getActiveElement(vim.content))
373 # If there’s only one input, `<tab>` would cycle to itself, making it feel
374 # like `<tab>` was not working. Then it’s better to let `<tab>` work as it
375 # usually does.
376 if index == -1 or vim.state.inputs.length <= 1
377 vim.state.inputs = null
378 return false
379 else
380 {inputs} = vim.state
381 nextInput = inputs[(index + direction) %% inputs.length]
382 utils.focusElement(nextInput, {select: true})
383 return true
384
385 commands.esc = (args) ->
386 {vim} = args
387 commands.blur_active_element(args)
388 vim.clearHover()
389
390 {document} = vim.content
391 if document.exitFullscreen
392 document.exitFullscreen()
393 else
394 document.mozCancelFullScreen()
395
396 commands.blur_active_element = ({vim}) ->
397 vim.state.explicitBodyFocus = false
398 utils.blurActiveElement(vim.content)
399
400 module.exports = commands
Imprint / Impressum