]> git.gir.st - VimFx.git/blob - extension/lib/commands-frame.coffee
Improve `zf` hover support: Emit mouse events
[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 vim.clearHover()
239 vim.setHover(element)
240
241 commands.click_marker_element = (args) ->
242 {vim, elementIndex, type, preventTargetBlank} = args
243 element = vim.state.markerElements[elementIndex]
244 if element.target == '_blank' and preventTargetBlank
245 targetReset = element.target
246 element.target = ''
247 if type == 'clickable-special'
248 element.click()
249 else
250 utils.simulateMouseEvents(element, 'click')
251 element.target = targetReset if targetReset
252
253 commands.copy_marker_element = ({vim, elementIndex, property}) ->
254 element = vim.state.markerElements[elementIndex]
255 utils.writeToClipboard(element[property])
256
257 commands.follow_pattern = ({vim, type, options}) ->
258 {document} = vim.content
259
260 # If there’s a `<link rel=prev/next>` element we use that.
261 for link in document.head?.getElementsByTagName('link')
262 # Also support `rel=previous`, just like Google.
263 if type == link.rel.toLowerCase().replace(/^previous$/, 'prev')
264 vim.content.location.href = link.href
265 return
266
267 # Otherwise we look for a link or button on the page that seems to go to the
268 # previous or next page.
269 candidates = document.querySelectorAll(options.pattern_selector)
270
271 # Note: Earlier patterns should be favored.
272 {patterns} = options
273
274 # Search for the prev/next patterns in the following attributes of the
275 # element. `rel` should be kept as the first attribute, since the standard way
276 # of marking up prev/next links (`rel="prev"` and `rel="next"`) should be
277 # favored. Even though some of these attributes only allow a fixed set of
278 # keywords, we pattern-match them anyways since lots of sites don’t follow the
279 # spec and use the attributes arbitrarily.
280 attrs = options.pattern_attrs
281
282 matchingLink = do ->
283 # First search in attributes (favoring earlier attributes) as it's likely
284 # that they are more specific than text contexts.
285 for attr in attrs
286 for regex in patterns
287 for element in candidates
288 return element if regex.test(element.getAttribute(attr))
289
290 # Then search in element contents.
291 for regex in patterns
292 for element in candidates
293 return element if regex.test(element.textContent)
294
295 return null
296
297 if matchingLink
298 utils.simulateMouseEvents(matchingLink, 'click')
299 else
300 vim.notify(translate("notification.follow_#{type}.none"))
301
302 commands.focus_text_input = ({vim, count = null}) ->
303 {lastFocusedTextInput} = vim.state
304 candidates = utils.querySelectorAllDeep(
305 vim.content, 'input, textarea, [contenteditable]'
306 )
307 inputs = Array.filter(candidates, (element) ->
308 return isTextInputElement(element) and utils.area(element) > 0
309 )
310 if lastFocusedTextInput and lastFocusedTextInput not in inputs
311 inputs.push(lastFocusedTextInput)
312 inputs.sort((a, b) -> a.tabIndex - b.tabIndex)
313
314 if inputs.length == 0
315 vim.notify(translate('notification.focus_text_input.none'))
316 return
317
318 num = switch
319 when count?
320 count
321 when lastFocusedTextInput
322 inputs.indexOf(lastFocusedTextInput) + 1
323 else
324 1
325 index = Math.min(num, inputs.length) - 1
326 select = (count? or not vim.state.hasFocusedTextInput)
327 utils.focusElement(inputs[index], {select})
328 vim.state.inputs = inputs
329
330 commands.clear_inputs = ({vim}) ->
331 vim.state.inputs = null
332
333 commands.move_focus = ({vim, direction}) ->
334 return false unless vim.state.inputs
335 index = vim.state.inputs.indexOf(utils.getActiveElement(vim.content))
336 # If there’s only one input, `<tab>` would cycle to itself, making it feel
337 # like `<tab>` was not working. Then it’s better to let `<tab>` work as it
338 # usually does.
339 if index == -1 or vim.state.inputs.length <= 1
340 vim.state.inputs = null
341 return false
342 else
343 {inputs} = vim.state
344 nextInput = inputs[(index + direction) %% inputs.length]
345 utils.focusElement(nextInput, {select: true})
346 return true
347
348 commands.esc = (args) ->
349 {vim} = args
350 commands.blur_active_element(args)
351 vim.clearHover()
352
353 {document} = vim.content
354 if document.exitFullscreen
355 document.exitFullscreen()
356 else
357 document.mozCancelFullScreen()
358
359 commands.blur_active_element = ({vim}) ->
360 vim.state.explicitBodyFocus = false
361 utils.blurActiveElement(vim.content)
362
363 module.exports = commands
Imprint / Impressum