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