]> git.gir.st - VimFx.git/blob - extension/lib/commands-frame.coffee
Add better hints support for CodeMirror
[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 utils.simulateMouseEvents(element, 'click')
301 element.target = targetReset if targetReset
302
303 commands.copy_marker_element = ({vim, elementIndex, property}) ->
304 {element} = vim.state.markerElements[elementIndex]
305 utils.writeToClipboard(element[property])
306
307 commands.follow_pattern = ({vim, type, options}) ->
308 {document} = vim.content
309
310 # If there’s a `<link rel=prev/next>` element we use that.
311 for link in document.head?.getElementsByTagName('link')
312 # Also support `rel=previous`, just like Google.
313 if type == link.rel.toLowerCase().replace(/^previous$/, 'prev')
314 vim.content.location.href = link.href
315 return
316
317 # Otherwise we look for a link or button on the page that seems to go to the
318 # previous or next page.
319 candidates = document.querySelectorAll(options.pattern_selector)
320
321 # Note: Earlier patterns should be favored.
322 {patterns} = options
323
324 # Search for the prev/next patterns in the following attributes of the
325 # element. `rel` should be kept as the first attribute, since the standard way
326 # of marking up prev/next links (`rel="prev"` and `rel="next"`) should be
327 # favored. Even though some of these attributes only allow a fixed set of
328 # keywords, we pattern-match them anyways since lots of sites don’t follow the
329 # spec and use the attributes arbitrarily.
330 attrs = options.pattern_attrs
331
332 matchingLink = do ->
333 # First search in attributes (favoring earlier attributes) as it's likely
334 # that they are more specific than text contexts.
335 for attr in attrs
336 for regex in patterns
337 for element in candidates
338 return element if regex.test(element.getAttribute(attr))
339
340 # Then search in element contents.
341 for regex in patterns
342 for element in candidates
343 return element if regex.test(element.textContent)
344
345 return null
346
347 if matchingLink
348 utils.simulateMouseEvents(matchingLink, 'click')
349 # When you go to the next page of GitHub’s code search results, the page is
350 # loaded with AJAX. GitHub then annoyingly focuses its search input. This
351 # autofocus cannot be prevented in a reliable way, because the case is
352 # indistinguishable from a button whose job is to focus some text input.
353 # However, in this command we know for sure that we can prevent the next
354 # focus. This must be done _after_ the click has been triggered, since
355 # clicks count as page interactions.
356 vim.markPageInteraction(false)
357 else
358 vim.notify(translate("notification.follow_#{type}.none"))
359
360 commands.focus_text_input = ({vim, count = null}) ->
361 {lastFocusedTextInput} = vim.state
362 candidates = utils.querySelectorAllDeep(
363 vim.content, 'input, textarea, [contenteditable]'
364 )
365 inputs = Array.filter(candidates, (element) ->
366 return isTextInputElement(element) and utils.area(element) > 0
367 )
368 if lastFocusedTextInput and lastFocusedTextInput not in inputs
369 inputs.push(lastFocusedTextInput)
370 inputs.sort((a, b) -> a.tabIndex - b.tabIndex)
371
372 if inputs.length == 0
373 vim.notify(translate('notification.focus_text_input.none'))
374 return
375
376 num = switch
377 when count?
378 count
379 when lastFocusedTextInput
380 inputs.indexOf(lastFocusedTextInput) + 1
381 else
382 1
383 index = Math.min(num, inputs.length) - 1
384 select = (count? or not vim.state.hasFocusedTextInput)
385 utils.focusElement(inputs[index], {select})
386 vim.state.inputs = inputs
387
388 commands.clear_inputs = ({vim}) ->
389 vim.state.inputs = null
390
391 commands.move_focus = ({vim, direction}) ->
392 return false unless vim.state.inputs
393 index = vim.state.inputs.indexOf(utils.getActiveElement(vim.content))
394 # If there’s only one input, `<tab>` would cycle to itself, making it feel
395 # like `<tab>` was not working. Then it’s better to let `<tab>` work as it
396 # usually does.
397 if index == -1 or vim.state.inputs.length <= 1
398 vim.state.inputs = null
399 return false
400 else
401 {inputs} = vim.state
402 nextInput = inputs[(index + direction) %% inputs.length]
403 utils.focusElement(nextInput, {select: true})
404 return true
405
406 commands.esc = (args) ->
407 {vim} = args
408 commands.blur_active_element(args)
409 vim.clearHover()
410
411 {document} = vim.content
412 if document.exitFullscreen
413 document.exitFullscreen()
414 else
415 document.mozCancelFullScreen()
416
417 commands.blur_active_element = ({vim}) ->
418 vim.state.explicitBodyFocus = false
419 utils.blurActiveElement(vim.content)
420
421 module.exports = commands
Imprint / Impressum