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