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