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