]> git.gir.st - VimFx.git/blob - extension/lib/commands-frame.coffee
Merge pull request #640 from akhodakivskiy/click-browser-element
[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 commands = {}
34
35 commands.go_up_path = ({vim, count = 1}) ->
36 {pathname} = vim.content.location
37 newPathname = pathname.replace(/// (?: /[^/]+ ){1,#{count}} /?$ ///, '')
38 if newPathname == pathname
39 vim.notify(translate('notification.go_up_path.limit'))
40 else
41 vim.content.location.pathname = newPathname
42
43 commands.go_to_root = ({vim}) ->
44 # `.origin` is `'null'` (as a string) on `about:` pages.
45 if "#{vim.content.location.origin}/" in [vim.content.location.href, 'null/']
46 vim.notify(translate('notification.go_up_path.limit'))
47 else
48 vim.content.location.href = vim.content.location.origin
49
50 commands.scroll = (args) ->
51 {vim} = args
52 activeElement = utils.getActiveElement(vim.content)
53 element =
54 # If no element is focused on the page, the the active element is the
55 # topmost `<body>`, and blurring it is a no-op. If it is scrollable, it
56 # means that you can’t blur it in order to scroll `<html>`. Therefore it may
57 # only be scrolled if it has been explicitly focused.
58 if vim.state.scrollableElements.has(activeElement) and
59 (activeElement != vim.content.document.body or
60 vim.state.explicitBodyFocus)
61 activeElement
62 else
63 vim.state.scrollableElements.filterSuitableDefault()
64 utils.scroll(element, args)
65
66 commands.mark_scroll_position = ({vim, keyStr, notify = true}) ->
67 element = vim.state.scrollableElements.filterSuitableDefault()
68 vim.state.marks[keyStr] = [element.scrollTop, element.scrollLeft]
69 if notify
70 vim.notify(translate('notification.mark_scroll_position.success', keyStr))
71
72 commands.scroll_to_mark = (args) ->
73 {vim, amounts: keyStr} = args
74 unless keyStr of vim.state.marks
75 vim.notify(translate('notification.scroll_to_mark.none', keyStr))
76 return
77
78 args.amounts = vim.state.marks[keyStr]
79 element = vim.state.scrollableElements.filterSuitableDefault()
80 utils.scroll(element, args)
81
82 helper_follow = ({id, combine = true}, matcher, {vim}) ->
83 hrefs = {}
84 vim.state.markerElements = []
85
86 filter = (element, getElementShape) ->
87 {type, semantic} = matcher({vim, element, getElementShape})
88
89 customMatcher = FRAME_SCRIPT_ENVIRONMENT.VimFxHintMatcher
90 if customMatcher
91 {type, semantic} = customMatcher(id, element, {type, semantic})
92
93 return unless type
94 return unless shape = getElementShape(element)
95
96 length = vim.state.markerElements.push(element)
97 wrapper = {type, semantic, shape, elementIndex: length - 1}
98
99 if wrapper.type == 'link'
100 {href} = element
101 wrapper.href = href
102
103 # Combine links with the same href.
104 if combine and wrapper.type == 'link' and
105 # If the element has an 'onclick' attribute we cannot be sure that all
106 # links with this href actually do the same thing. On some pages, such
107 # as startpage.com, actual proper links have the 'onclick' attribute,
108 # so we can’t exclude such links in `utils.isProperLink`.
109 not element.hasAttribute('onclick')
110 if href of hrefs
111 parent = hrefs[href]
112 wrapper.parentIndex = parent.elementIndex
113 parent.shape.area += wrapper.shape.area
114 parent.numChildren++
115 else
116 wrapper.numChildren = 0
117 hrefs[href] = wrapper
118
119 return wrapper
120
121 return hints.getMarkableElementsAndViewport(vim.content, filter)
122
123 commands.follow = helper_follow.bind(null, {id: 'normal'},
124 ({vim, element, getElementShape}) ->
125 document = element.ownerDocument
126 isXUL = (document instanceof XULDocument)
127 type = null
128 semantic = true
129 switch
130 when isProperLink(element)
131 type = 'link'
132 when isTypingElement(element) or isContentEditable(element)
133 type = 'text'
134 when element.tabIndex > -1 and
135 not (isXUL and element.nodeName.endsWith('box') and
136 element.nodeName != 'checkbox')
137 type = 'clickable'
138 unless isXUL or element.nodeName in ['A', 'INPUT', 'BUTTON']
139 semantic = false
140 when element != vim.state.scrollableElements.largest and
141 vim.state.scrollableElements.has(element)
142 type = 'scrollable'
143 when element.hasAttribute('onclick') or
144 element.hasAttribute('onmousedown') or
145 element.hasAttribute('onmouseup') or
146 element.hasAttribute('oncommand') or
147 element.getAttribute('role') in ['link', 'button'] or
148 # Twitter special-case.
149 element.classList.contains('js-new-tweets-bar') or
150 # Feedly special-case.
151 element.hasAttribute('data-app-action') or
152 element.hasAttribute('data-uri') or
153 element.hasAttribute('data-page-action')
154 type = 'clickable'
155 semantic = false
156 # Facebook special-case (comment fields).
157 when element.parentElement?.classList.contains('UFIInputContainer')
158 type = 'clickable-special'
159 # Putting markers on `<label>` elements is generally redundant, because
160 # its `<input>` gets one. However, some sites hide the actual `<input>`
161 # but keeps the `<label>` to click, either for styling purposes or to keep
162 # the `<input>` hidden until it is used. In those cases we should add a
163 # marker for the `<label>`.
164 when element.nodeName == 'LABEL'
165 input =
166 if element.htmlFor
167 document.getElementById(element.htmlFor)
168 else
169 element.querySelector('input, textarea, select')
170 if input and not getElementShape(input)
171 type = 'clickable'
172 # Elements that have “button” somewhere in the class might be clickable,
173 # unless they contain a real link or button or yet an element with
174 # “button” somewhere in the class, in which case they likely are
175 # “button-wrapper”s. (`<SVG element>.className` is not a string!)
176 when not isXUL and typeof element.className == 'string' and
177 element.className.toLowerCase().includes('button')
178 unless element.querySelector('a, button, [class*=button]')
179 type = 'clickable'
180 semantic = false
181 # When viewing an image it should get a marker to toggle zoom.
182 when document.body?.childElementCount == 1 and
183 element.nodeName == 'IMG' and
184 (element.classList.contains('overflowing') or
185 element.classList.contains('shrinkToFit'))
186 type = 'clickable'
187 return {type, semantic}
188 )
189
190 commands.follow_in_tab = helper_follow.bind(null, {id: 'tab'},
191 ({element}) ->
192 type = if isProperLink(element) then 'link' else null
193 return {type, semantic: true}
194 )
195
196 commands.follow_copy = helper_follow.bind(null, {id: 'copy'},
197 ({element}) ->
198 type = switch
199 when isProperLink(element) then 'link'
200 when isTypingElement(element) then 'text'
201 when isContentEditable(element) then 'contenteditable'
202 else null
203 return {type, semantic: true}
204 )
205
206 commands.follow_focus = helper_follow.bind(null, {id: 'focus', combine: false},
207 ({vim, element}) ->
208 type = switch
209 when element.tabIndex > -1
210 'focusable'
211 when element != vim.state.scrollableElements.largest and
212 vim.state.scrollableElements.has(element)
213 'scrollable'
214 else
215 null
216 return {type, semantic: true}
217 )
218
219 commands.focus_marker_element = ({vim, elementIndex, options}) ->
220 element = vim.state.markerElements[elementIndex]
221 # To be able to focus scrollable elements, `FLAG_BYKEY` _has_ to be used.
222 options.flag = 'FLAG_BYKEY' if vim.state.scrollableElements.has(element)
223 utils.focusElement(element, options)
224
225 commands.click_marker_element = (args) ->
226 {vim, elementIndex, type, preventTargetBlank} = args
227 element = vim.state.markerElements[elementIndex]
228 if element.target == '_blank' and preventTargetBlank
229 targetReset = element.target
230 element.target = ''
231 if type == 'clickable-special'
232 element.click()
233 else
234 utils.simulateClick(element)
235 element.target = targetReset if targetReset
236
237 commands.copy_marker_element = ({vim, elementIndex, property}) ->
238 element = vim.state.markerElements[elementIndex]
239 utils.writeToClipboard(element[property])
240
241 commands.follow_pattern = ({vim, type, options}) ->
242 {document} = vim.content
243
244 # If there’s a `<link rel=prev/next>` element we use that.
245 for link in document.head?.getElementsByTagName('link')
246 # Also support `rel=previous`, just like Google.
247 if type == link.rel.toLowerCase().replace(/^previous$/, 'prev')
248 vim.content.location.href = link.href
249 return
250
251 # Otherwise we look for a link or button on the page that seems to go to the
252 # previous or next page.
253 candidates = document.querySelectorAll(options.pattern_selector)
254
255 # Note: Earlier patterns should be favored.
256 {patterns} = options
257
258 # Search for the prev/next patterns in the following attributes of the
259 # element. `rel` should be kept as the first attribute, since the standard way
260 # of marking up prev/next links (`rel="prev"` and `rel="next"`) should be
261 # favored. Even though some of these attributes only allow a fixed set of
262 # keywords, we pattern-match them anyways since lots of sites don’t follow the
263 # spec and use the attributes arbitrarily.
264 attrs = options.pattern_attrs
265
266 matchingLink = do ->
267 # First search in attributes (favoring earlier attributes) as it's likely
268 # that they are more specific than text contexts.
269 for attr in attrs
270 for regex in patterns
271 for element in candidates
272 return element if regex.test(element.getAttribute(attr))
273
274 # Then search in element contents.
275 for regex in patterns
276 for element in candidates
277 return element if regex.test(element.textContent)
278
279 return null
280
281 if matchingLink
282 utils.simulateClick(matchingLink)
283 else
284 vim.notify(translate("notification.follow_#{type}.none"))
285
286 commands.focus_text_input = ({vim, count = null}) ->
287 {lastFocusedTextInput} = vim.state
288 inputs = Array.filter(
289 utils.querySelectorAllDeep(vim.content, 'input, textarea'), (element) ->
290 return isTextInputElement(element) and utils.area(element) > 0
291 )
292 if lastFocusedTextInput and lastFocusedTextInput not in inputs
293 inputs.push(lastFocusedTextInput)
294 inputs.sort((a, b) -> a.tabIndex - b.tabIndex)
295
296 if inputs.length == 0
297 vim.notify(translate('notification.focus_text_input.none'))
298 return
299
300 num = switch
301 when count?
302 count
303 when lastFocusedTextInput
304 inputs.indexOf(lastFocusedTextInput) + 1
305 else
306 1
307 index = Math.min(num, inputs.length) - 1
308 select = (count? or not vim.state.hasFocusedTextInput)
309 utils.focusElement(inputs[index], {select})
310 vim.state.inputs = inputs
311
312 commands.clear_inputs = ({vim}) ->
313 vim.state.inputs = null
314
315 commands.move_focus = ({vim, direction}) ->
316 return false unless vim.state.inputs
317 index = vim.state.inputs.indexOf(utils.getActiveElement(vim.content))
318 # If there’s only one input, `<tab>` would cycle to itself, making it feel
319 # like `<tab>` was not working. Then it’s better to let `<tab>` work as it
320 # usually does.
321 if index == -1 or vim.state.inputs.length <= 1
322 vim.state.inputs = null
323 return false
324 else
325 {inputs} = vim.state
326 nextInput = inputs[(index + direction) %% inputs.length]
327 utils.focusElement(nextInput, {select: true})
328 return true
329
330 commands.esc = (args) ->
331 commands.blur_active_element(args)
332
333 {document} = args.vim.content
334 if document.exitFullscreen
335 document.exitFullscreen()
336 else
337 document.mozCancelFullScreen()
338
339 commands.blur_active_element = ({vim}) ->
340 vim.state.explicitBodyFocus = false
341 utils.blurActiveElement(vim.content)
342
343 module.exports = commands
Imprint / Impressum