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