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