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