]> git.gir.st - VimFx.git/blob - extension/lib/commands-frame.coffee
Merge branch 'lint'
[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 += 1
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)
243 'link'
244 when isContentEditable(element)
245 'contenteditable'
246 when isTypingElement(element)
247 'text'
248 else
249 null
250 return {type, semantic: true}
251 )
252
253 commands.follow_focus = helper_follow.bind(null, {id: 'focus', combine: false},
254 ({vim, element}) ->
255 type = switch
256 when element.tabIndex > -1
257 'focusable'
258 when element != vim.state.scrollableElements.largest and
259 vim.state.scrollableElements.has(element)
260 'scrollable'
261 else
262 null
263 return {type, semantic: true}
264 )
265
266 commands.focus_marker_element = ({vim, elementIndex, options}) ->
267 {element} = vim.state.markerElements[elementIndex]
268 # To be able to focus scrollable elements, `FLAG_BYKEY` _has_ to be used.
269 options.flag = 'FLAG_BYKEY' if vim.state.scrollableElements.has(element)
270 utils.focusElement(element, options)
271 vim.clearHover()
272 vim.setHover(element)
273
274 commands.click_marker_element = (args) ->
275 {vim, elementIndex, type, preventTargetBlank} = args
276 {element} = vim.state.markerElements[elementIndex]
277 if element.target == '_blank' and preventTargetBlank
278 targetReset = element.target
279 element.target = ''
280 if type == 'clickable-special'
281 element.click()
282 else
283 utils.simulateMouseEvents(element, 'click')
284 element.target = targetReset if targetReset
285
286 commands.copy_marker_element = ({vim, elementIndex, property}) ->
287 {element} = vim.state.markerElements[elementIndex]
288 utils.writeToClipboard(element[property])
289
290 commands.follow_pattern = ({vim, type, options}) ->
291 {document} = vim.content
292
293 # If there’s a `<link rel=prev/next>` element we use that.
294 for link in document.head?.getElementsByTagName('link')
295 # Also support `rel=previous`, just like Google.
296 if type == link.rel.toLowerCase().replace(/^previous$/, 'prev')
297 vim.content.location.href = link.href
298 return
299
300 # Otherwise we look for a link or button on the page that seems to go to the
301 # previous or next page.
302 candidates = document.querySelectorAll(options.pattern_selector)
303
304 # Note: Earlier patterns should be favored.
305 {patterns} = options
306
307 # Search for the prev/next patterns in the following attributes of the
308 # element. `rel` should be kept as the first attribute, since the standard way
309 # of marking up prev/next links (`rel="prev"` and `rel="next"`) should be
310 # favored. Even though some of these attributes only allow a fixed set of
311 # keywords, we pattern-match them anyways since lots of sites don’t follow the
312 # spec and use the attributes arbitrarily.
313 attrs = options.pattern_attrs
314
315 matchingLink = do ->
316 # First search in attributes (favoring earlier attributes) as it's likely
317 # that they are more specific than text contexts.
318 for attr in attrs
319 for regex in patterns
320 for element in candidates
321 return element if regex.test(element.getAttribute(attr))
322
323 # Then search in element contents.
324 for regex in patterns
325 for element in candidates
326 return element if regex.test(element.textContent)
327
328 return null
329
330 if matchingLink
331 utils.simulateMouseEvents(matchingLink, 'click')
332 # When you go to the next page of GitHub’s code search results, the page is
333 # loaded with AJAX. GitHub then annoyingly focuses its search input. This
334 # autofocus cannot be prevented in a reliable way, because the case is
335 # indistinguishable from a button whose job is to focus some text input.
336 # However, in this command we know for sure that we can prevent the next
337 # focus. This must be done _after_ the click has been triggered, since
338 # clicks count as page interactions.
339 vim.markPageInteraction(false)
340 else
341 vim.notify(translate("notification.follow_#{type}.none"))
342
343 commands.focus_text_input = ({vim, count = null}) ->
344 {lastFocusedTextInput} = vim.state
345 candidates = utils.querySelectorAllDeep(
346 vim.content, 'input, textarea, [contenteditable]'
347 )
348 inputs = Array.filter(candidates, (element) ->
349 return isTextInputElement(element) and utils.area(element) > 0
350 )
351 if lastFocusedTextInput and lastFocusedTextInput not in inputs
352 inputs.push(lastFocusedTextInput)
353 inputs.sort((a, b) -> a.tabIndex - b.tabIndex)
354
355 if inputs.length == 0
356 vim.notify(translate('notification.focus_text_input.none'))
357 return
358
359 num = switch
360 when count?
361 count
362 when lastFocusedTextInput
363 inputs.indexOf(lastFocusedTextInput) + 1
364 else
365 1
366 index = Math.min(num, inputs.length) - 1
367 select = (count? or not vim.state.hasFocusedTextInput)
368 utils.focusElement(inputs[index], {select})
369 vim.state.inputs = inputs
370
371 commands.clear_inputs = ({vim}) ->
372 vim.state.inputs = null
373
374 commands.move_focus = ({vim, direction}) ->
375 return false unless vim.state.inputs
376 index = vim.state.inputs.indexOf(utils.getActiveElement(vim.content))
377 # If there’s only one input, `<tab>` would cycle to itself, making it feel
378 # like `<tab>` was not working. Then it’s better to let `<tab>` work as it
379 # usually does.
380 if index == -1 or vim.state.inputs.length <= 1
381 vim.state.inputs = null
382 return false
383 else
384 {inputs} = vim.state
385 nextInput = inputs[(index + direction) %% inputs.length]
386 utils.focusElement(nextInput, {select: true})
387 return true
388
389 commands.esc = (args) ->
390 {vim} = args
391 commands.blur_active_element(args)
392 vim.clearHover()
393
394 {document} = vim.content
395 if document.exitFullscreen
396 document.exitFullscreen()
397 else
398 document.mozCancelFullScreen()
399
400 commands.blur_active_element = ({vim}) ->
401 vim.state.explicitBodyFocus = false
402 utils.blurActiveElement(vim.content)
403
404 module.exports = commands
Imprint / Impressum