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