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