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