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