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