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