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