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