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