]> git.gir.st - VimFx.git/blob - extension/lib/commands-frame.coffee
Make `gi` work in `about:config`
[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 element.tabIndex > -1 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' and
185 not (isXUL and element.localName?.endsWith?('box') and
186 element.localName != 'checkbox')
187 type = 'clickable'
188 unless isXUL or element.localName in ['a', 'input', 'button']
189 semantic = false
190 when element != vim.state.scrollableElements.largest and
191 vim.state.scrollableElements.has(element)
192 type = 'scrollable'
193 when element.hasAttribute?('onclick') or
194 element.hasAttribute?('onmousedown') or
195 element.hasAttribute?('onmouseup') or
196 element.hasAttribute?('oncommand') or
197 # Twitter.
198 element.classList?.contains('js-new-tweets-bar') or
199 # Feedly.
200 element.hasAttribute?('data-app-action') or
201 element.hasAttribute?('data-uri') or
202 element.hasAttribute?('data-page-action') or
203 # Google Drive Document.
204 element.classList?.contains('kix-appview-editor')
205 type = 'clickable'
206 semantic = false
207 # Facebook comment fields.
208 when element.parentElement?.classList?.contains('UFIInputContainer')
209 type = 'clickable-special'
210 # Putting markers on `<label>` elements is generally redundant, because
211 # its `<input>` gets one. However, some sites hide the actual `<input>`
212 # but keeps the `<label>` to click, either for styling purposes or to keep
213 # the `<input>` hidden until it is used. In those cases we should add a
214 # marker for the `<label>`.
215 when element.localName == 'label'
216 input =
217 if element.htmlFor
218 document.getElementById?(element.htmlFor)
219 else
220 element.querySelector?('input, textarea, select')
221 if input and not getElementShape(input)
222 type = 'clickable'
223 # Last resort checks for elements that might be clickable because of
224 # JavaScript.
225 when (not isXUL and
226 # It is common to listen for clicks on `<html>` or `<body>`. Don’t
227 # waste time on them.
228 element not in [document.documentElement, document.body]) and
229 (utils.includes(element.className, 'button') or
230 utils.includes(element.getAttribute?('aria-label'), 'close') or
231 # Do this last as it’s a potentially expensive check.
232 utils.hasEventListeners(element, 'click'))
233 # Make a quick check for likely clickable descendants, to reduce the
234 # number of false positives. the element might be a “button-wrapper” or
235 # a large element with a click-tracking event listener.
236 unless element.querySelector?('a, button, input, [class*=button]')
237 type = 'clickable'
238 semantic = false
239 # When viewing an image it should get a marker to toggle zoom. This is the
240 # most unlikely rule to match, so keep it last.
241 when document.body?.childElementCount == 1 and
242 element.localName == 'img' and
243 (element.classList?.contains('overflowing') or
244 element.classList?.contains('shrinkToFit'))
245 type = 'clickable'
246 type = null if isXUL and element.classList?.contains('textbox-input')
247 return {type, semantic}
248 )
249
250 commands.follow_in_tab = helper_follow.bind(null, {id: 'tab'},
251 ({element}) ->
252 type = if isProperLink(element) then 'link' else null
253 return {type, semantic: true}
254 )
255
256 commands.follow_copy = helper_follow.bind(null, {id: 'copy'},
257 ({element}) ->
258 type = switch
259 when isProperLink(element)
260 'link'
261 when isContentEditable(element)
262 'contenteditable'
263 when isTypingElement(element)
264 'text'
265 else
266 null
267 return {type, semantic: true}
268 )
269
270 commands.follow_focus = helper_follow.bind(null, {id: 'focus', combine: false},
271 ({vim, element}) ->
272 type = switch
273 when element.tabIndex > -1
274 'focusable'
275 when element != vim.state.scrollableElements.largest and
276 vim.state.scrollableElements.has(element)
277 'scrollable'
278 else
279 null
280 return {type, semantic: true}
281 )
282
283 commands.focus_marker_element = ({vim, elementIndex, options}) ->
284 {element} = vim.state.markerElements[elementIndex]
285 # To be able to focus scrollable elements, `FLAG_BYKEY` _has_ to be used.
286 options.flag = 'FLAG_BYKEY' if vim.state.scrollableElements.has(element)
287 utils.focusElement(element, options)
288 vim.clearHover()
289 vim.setHover(element)
290
291 commands.click_marker_element = (args) ->
292 {vim, elementIndex, type, preventTargetBlank} = args
293 {element} = vim.state.markerElements[elementIndex]
294 if element.target == '_blank' and preventTargetBlank
295 targetReset = element.target
296 element.target = ''
297 if type == 'clickable-special'
298 element.click()
299 else
300 isXUL = (element.ownerDocument instanceof XULDocument)
301 utils.simulateMouseEvents(element, if isXUL then 'click-xul' else 'click')
302 element.target = targetReset if targetReset
303
304 commands.copy_marker_element = ({vim, elementIndex, property}) ->
305 {element} = vim.state.markerElements[elementIndex]
306 utils.writeToClipboard(element[property])
307
308 commands.follow_pattern = ({vim, type, options}) ->
309 {document} = vim.content
310
311 # If there’s a `<link rel=prev/next>` element we use that.
312 for link in document.head?.getElementsByTagName('link')
313 # Also support `rel=previous`, just like Google.
314 if type == link.rel.toLowerCase().replace(/^previous$/, 'prev')
315 vim.content.location.href = link.href
316 return
317
318 # Otherwise we look for a link or button on the page that seems to go to the
319 # previous or next page.
320 candidates = document.querySelectorAll(options.pattern_selector)
321
322 # Note: Earlier patterns should be favored.
323 {patterns} = options
324
325 # Search for the prev/next patterns in the following attributes of the
326 # element. `rel` should be kept as the first attribute, since the standard way
327 # of marking up prev/next links (`rel="prev"` and `rel="next"`) should be
328 # favored. Even though some of these attributes only allow a fixed set of
329 # keywords, we pattern-match them anyways since lots of sites don’t follow the
330 # spec and use the attributes arbitrarily.
331 attrs = options.pattern_attrs
332
333 matchingLink = do ->
334 # First search in attributes (favoring earlier attributes) as it's likely
335 # that they are more specific than text contexts.
336 for attr in attrs
337 for regex in patterns
338 for element in candidates
339 return element if regex.test(element.getAttribute?(attr))
340
341 # Then search in element contents.
342 for regex in patterns
343 for element in candidates
344 return element if regex.test(element.textContent)
345
346 return null
347
348 if matchingLink
349 utils.simulateMouseEvents(matchingLink, 'click')
350 # When you go to the next page of GitHub’s code search results, the page is
351 # loaded with AJAX. GitHub then annoyingly focuses its search input. This
352 # autofocus cannot be prevented in a reliable way, because the case is
353 # indistinguishable from a button whose job is to focus some text input.
354 # However, in this command we know for sure that we can prevent the next
355 # focus. This must be done _after_ the click has been triggered, since
356 # clicks count as page interactions.
357 vim.markPageInteraction(false)
358 else
359 vim.notify(translate("notification.follow_#{type}.none"))
360
361 commands.focus_text_input = ({vim, count = null}) ->
362 {lastFocusedTextInput} = vim.state
363
364 if lastFocusedTextInput and utils.isDetached(lastFocusedTextInput)
365 lastFocusedTextInput = null
366
367 candidates = utils.querySelectorAllDeep(
368 vim.content, 'input, textarea, textbox, [contenteditable]'
369 )
370 inputs = Array.filter(candidates, (element) ->
371 return isTextInputElement(element) and utils.area(element) > 0
372 )
373 if lastFocusedTextInput and lastFocusedTextInput not in inputs
374 inputs.push(lastFocusedTextInput)
375 inputs.sort((a, b) -> a.tabIndex - b.tabIndex)
376
377 if inputs.length == 0
378 vim.notify(translate('notification.focus_text_input.none'))
379 return
380
381 num = switch
382 when count?
383 count
384 when lastFocusedTextInput
385 inputs.indexOf(lastFocusedTextInput) + 1
386 else
387 1
388 index = Math.min(num, inputs.length) - 1
389 select = (count? or not vim.state.hasFocusedTextInput)
390 utils.focusElement(inputs[index], {select})
391 vim.state.inputs = inputs
392
393 commands.clear_inputs = ({vim}) ->
394 vim.state.inputs = null
395
396 commands.move_focus = ({vim, direction}) ->
397 return false unless vim.state.inputs
398 index = vim.state.inputs.indexOf(utils.getActiveElement(vim.content))
399 # If there’s only one input, `<tab>` would cycle to itself, making it feel
400 # like `<tab>` was not working. Then it’s better to let `<tab>` work as it
401 # usually does.
402 if index == -1 or vim.state.inputs.length <= 1
403 vim.state.inputs = null
404 return false
405 else
406 {inputs} = vim.state
407 nextInput = inputs[(index + direction) %% inputs.length]
408 utils.focusElement(nextInput, {select: true})
409 return true
410
411 commands.esc = (args) ->
412 {vim} = args
413 commands.blur_active_element(args)
414 vim.clearHover()
415
416 {document} = vim.content
417 if document.exitFullscreen
418 document.exitFullscreen()
419 else
420 document.mozCancelFullScreen()
421
422 commands.blur_active_element = ({vim}) ->
423 vim.state.explicitBodyFocus = false
424 utils.blurActiveElement(vim.content)
425
426 module.exports = commands
Imprint / Impressum