]> git.gir.st - VimFx.git/blob - extension/lib/commands-frame.coffee
Don't use the same hint for GitHub's diff expansion buttons
[VimFx.git] / extension / lib / commands-frame.coffee
1 ###
2 # Copyright Simon Lydell 2015.
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 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 length = vim.state.markerElements.push(element)
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++
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 when isProperLink(element)
141 type = 'link'
142 when isTypingElement(element)
143 type = 'text'
144 when element.tabIndex > -1 and
145 # Google Drive Documents. The hint for this element would cover the
146 # real hint that allows you to focus the document to start typing.
147 element.id != 'docs-editor' and
148 not (isXUL and element.nodeName.endsWith('box') and
149 element.nodeName != 'checkbox')
150 type = 'clickable'
151 unless isXUL or element.nodeName in ['A', 'INPUT', 'BUTTON']
152 semantic = false
153 when element != vim.state.scrollableElements.largest and
154 vim.state.scrollableElements.has(element)
155 type = 'scrollable'
156 when element.hasAttribute('onclick') or
157 element.hasAttribute('onmousedown') or
158 element.hasAttribute('onmouseup') or
159 element.hasAttribute('oncommand') or
160 element.getAttribute('role') in CLICKABLE_ARIA_ROLES or
161 # Twitter.
162 element.classList.contains('js-new-tweets-bar') or
163 # Feedly.
164 element.hasAttribute('data-app-action') or
165 element.hasAttribute('data-uri') or
166 element.hasAttribute('data-page-action') or
167 # CodeMirror.
168 element.classList?.contains('CodeMirror-scroll') or
169 # Google Drive Document.
170 element.classList?.contains('kix-appview-editor')
171 type = 'clickable'
172 semantic = false
173 # Facebook comment fields.
174 when element.parentElement?.classList.contains('UFIInputContainer')
175 type = 'clickable-special'
176 # Putting markers on `<label>` elements is generally redundant, because
177 # its `<input>` gets one. However, some sites hide the actual `<input>`
178 # but keeps the `<label>` to click, either for styling purposes or to keep
179 # the `<input>` hidden until it is used. In those cases we should add a
180 # marker for the `<label>`.
181 when element.nodeName == 'LABEL'
182 input =
183 if element.htmlFor
184 document.getElementById(element.htmlFor)
185 else
186 element.querySelector('input, textarea, select')
187 if input and not getElementShape(input)
188 type = 'clickable'
189 # Elements that have “button” somewhere in the class might be clickable,
190 # unless they contain a real link or button or yet an element with
191 # “button” somewhere in the class, in which case they likely are
192 # “button-wrapper”s. (`<SVG element>.className` is not a string!)
193 when not isXUL and typeof element.className == 'string' and
194 element.className.toLowerCase().includes('button')
195 unless element.querySelector('a, button, input, [class*=button]')
196 type = 'clickable'
197 semantic = false
198 # When viewing an image it should get a marker to toggle zoom.
199 when document.body?.childElementCount == 1 and
200 element.nodeName == 'IMG' and
201 (element.classList.contains('overflowing') or
202 element.classList.contains('shrinkToFit'))
203 type = 'clickable'
204 return {type, semantic}
205 )
206
207 commands.follow_in_tab = helper_follow.bind(null, {id: 'tab'},
208 ({element}) ->
209 type = if isProperLink(element) then 'link' else null
210 return {type, semantic: true}
211 )
212
213 commands.follow_copy = helper_follow.bind(null, {id: 'copy'},
214 ({element}) ->
215 type = switch
216 when isProperLink(element) then 'link'
217 when isContentEditable(element) then 'contenteditable'
218 when isTypingElement(element) then 'text'
219 else null
220 return {type, semantic: true}
221 )
222
223 commands.follow_focus = helper_follow.bind(null, {id: 'focus', combine: false},
224 ({vim, element}) ->
225 type = switch
226 when element.tabIndex > -1
227 'focusable'
228 when element != vim.state.scrollableElements.largest and
229 vim.state.scrollableElements.has(element)
230 'scrollable'
231 else
232 null
233 return {type, semantic: true}
234 )
235
236 commands.focus_marker_element = ({vim, elementIndex, options}) ->
237 element = vim.state.markerElements[elementIndex]
238 # To be able to focus scrollable elements, `FLAG_BYKEY` _has_ to be used.
239 options.flag = 'FLAG_BYKEY' if vim.state.scrollableElements.has(element)
240 utils.focusElement(element, options)
241 vim.clearHover()
242 vim.setHover(element)
243
244 commands.click_marker_element = (args) ->
245 {vim, elementIndex, type, preventTargetBlank} = args
246 element = vim.state.markerElements[elementIndex]
247 if element.target == '_blank' and preventTargetBlank
248 targetReset = element.target
249 element.target = ''
250 if type == 'clickable-special'
251 element.click()
252 else
253 utils.simulateMouseEvents(element, 'click')
254 element.target = targetReset if targetReset
255
256 commands.copy_marker_element = ({vim, elementIndex, property}) ->
257 element = vim.state.markerElements[elementIndex]
258 utils.writeToClipboard(element[property])
259
260 commands.follow_pattern = ({vim, type, options}) ->
261 {document} = vim.content
262
263 # If there’s a `<link rel=prev/next>` element we use that.
264 for link in document.head?.getElementsByTagName('link')
265 # Also support `rel=previous`, just like Google.
266 if type == link.rel.toLowerCase().replace(/^previous$/, 'prev')
267 vim.content.location.href = link.href
268 return
269
270 # Otherwise we look for a link or button on the page that seems to go to the
271 # previous or next page.
272 candidates = document.querySelectorAll(options.pattern_selector)
273
274 # Note: Earlier patterns should be favored.
275 {patterns} = options
276
277 # Search for the prev/next patterns in the following attributes of the
278 # element. `rel` should be kept as the first attribute, since the standard way
279 # of marking up prev/next links (`rel="prev"` and `rel="next"`) should be
280 # favored. Even though some of these attributes only allow a fixed set of
281 # keywords, we pattern-match them anyways since lots of sites don’t follow the
282 # spec and use the attributes arbitrarily.
283 attrs = options.pattern_attrs
284
285 matchingLink = do ->
286 # First search in attributes (favoring earlier attributes) as it's likely
287 # that they are more specific than text contexts.
288 for attr in attrs
289 for regex in patterns
290 for element in candidates
291 return element if regex.test(element.getAttribute(attr))
292
293 # Then search in element contents.
294 for regex in patterns
295 for element in candidates
296 return element if regex.test(element.textContent)
297
298 return null
299
300 if matchingLink
301 utils.simulateMouseEvents(matchingLink, 'click')
302 else
303 vim.notify(translate("notification.follow_#{type}.none"))
304
305 commands.focus_text_input = ({vim, count = null}) ->
306 {lastFocusedTextInput} = vim.state
307 candidates = utils.querySelectorAllDeep(
308 vim.content, 'input, textarea, [contenteditable]'
309 )
310 inputs = Array.filter(candidates, (element) ->
311 return isTextInputElement(element) and utils.area(element) > 0
312 )
313 if lastFocusedTextInput and lastFocusedTextInput not in inputs
314 inputs.push(lastFocusedTextInput)
315 inputs.sort((a, b) -> a.tabIndex - b.tabIndex)
316
317 if inputs.length == 0
318 vim.notify(translate('notification.focus_text_input.none'))
319 return
320
321 num = switch
322 when count?
323 count
324 when lastFocusedTextInput
325 inputs.indexOf(lastFocusedTextInput) + 1
326 else
327 1
328 index = Math.min(num, inputs.length) - 1
329 select = (count? or not vim.state.hasFocusedTextInput)
330 utils.focusElement(inputs[index], {select})
331 vim.state.inputs = inputs
332
333 commands.clear_inputs = ({vim}) ->
334 vim.state.inputs = null
335
336 commands.move_focus = ({vim, direction}) ->
337 return false unless vim.state.inputs
338 index = vim.state.inputs.indexOf(utils.getActiveElement(vim.content))
339 # If there’s only one input, `<tab>` would cycle to itself, making it feel
340 # like `<tab>` was not working. Then it’s better to let `<tab>` work as it
341 # usually does.
342 if index == -1 or vim.state.inputs.length <= 1
343 vim.state.inputs = null
344 return false
345 else
346 {inputs} = vim.state
347 nextInput = inputs[(index + direction) %% inputs.length]
348 utils.focusElement(nextInput, {select: true})
349 return true
350
351 commands.esc = (args) ->
352 {vim} = args
353 commands.blur_active_element(args)
354 vim.clearHover()
355
356 {document} = vim.content
357 if document.exitFullscreen
358 document.exitFullscreen()
359 else
360 document.mozCancelFullScreen()
361
362 commands.blur_active_element = ({vim}) ->
363 vim.state.explicitBodyFocus = false
364 utils.blurActiveElement(vim.content)
365
366 module.exports = commands
Imprint / Impressum