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