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