]> git.gir.st - VimFx.git/blob - extension/lib/utils.coffee
Recognize more elements as clickable
[VimFx.git] / extension / lib / utils.coffee
1 ###
2 # Copyright Anton Khodakivskiy 2012, 2013, 2014.
3 # Copyright Simon Lydell 2013, 2014, 2015, 2016.
4 # Copyright Wang Zhuochun 2013.
5 #
6 # This file is part of VimFx.
7 #
8 # VimFx is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU General Public License as published by
10 # the Free Software Foundation, either version 3 of the License, or
11 # (at your option) any later version.
12 #
13 # VimFx is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 # GNU General Public License for more details.
17 #
18 # You should have received a copy of the GNU General Public License
19 # along with VimFx. If not, see <http://www.gnu.org/licenses/>.
20 ###
21
22 # This file contains lots of different helper functions.
23
24 nsIClipboardHelper = Cc['@mozilla.org/widget/clipboardhelper;1']
25 .getService(Ci.nsIClipboardHelper)
26 nsIDomUtils = Cc['@mozilla.org/inspector/dom-utils;1']
27 .getService(Ci.inIDOMUtils)
28 nsIEventListenerService = Cc['@mozilla.org/eventlistenerservice;1']
29 .getService(Ci.nsIEventListenerService)
30 nsIFocusManager = Cc['@mozilla.org/focus-manager;1']
31 .getService(Ci.nsIFocusManager)
32 nsIStyleSheetService = Cc['@mozilla.org/content/style-sheet-service;1']
33 .getService(Ci.nsIStyleSheetService)
34 nsIWindowMediator = Cc['@mozilla.org/appshell/window-mediator;1']
35 .getService(Ci.nsIWindowMediator)
36
37 HTMLAnchorElement = Ci.nsIDOMHTMLAnchorElement
38 HTMLButtonElement = Ci.nsIDOMHTMLButtonElement
39 HTMLInputElement = Ci.nsIDOMHTMLInputElement
40 HTMLTextAreaElement = Ci.nsIDOMHTMLTextAreaElement
41 HTMLSelectElement = Ci.nsIDOMHTMLSelectElement
42 HTMLBodyElement = Ci.nsIDOMHTMLBodyElement
43 XULDocument = Ci.nsIDOMXULDocument
44 XULButtonElement = Ci.nsIDOMXULButtonElement
45 XULControlElement = Ci.nsIDOMXULControlElement
46 XULMenuListElement = Ci.nsIDOMXULMenuListElement
47 XULTextBoxElement = Ci.nsIDOMXULTextBoxElement
48
49 # Full chains of events for different mouse actions. ('command' is for XUL
50 # elements.)
51 EVENTS_CLICK = ['mousedown', 'mouseup', 'click', 'command']
52 EVENTS_HOVER_START = ['mouseover', 'mouseenter', 'mousemove']
53 EVENTS_HOVER_END = ['mouseout', 'mouseleave']
54
55
56
57 # Element classification helpers
58
59 isActivatable = (element) ->
60 return element instanceof HTMLAnchorElement or
61 element instanceof HTMLButtonElement or
62 (element instanceof HTMLInputElement and element.type in [
63 'button', 'submit', 'reset', 'image'
64 ]) or
65 element instanceof XULButtonElement
66
67 isAdjustable = (element) ->
68 return element instanceof HTMLInputElement and element.type in [
69 'checkbox', 'radio', 'file', 'color'
70 'date', 'time', 'datetime', 'datetime-local', 'month', 'week'
71 ] or
72 element instanceof XULControlElement or
73 # Youtube special case.
74 element.classList?.contains('html5-video-player') or
75 element.classList?.contains('ytp-button')
76
77 isContentEditable = (element) ->
78 return element.isContentEditable or
79 isIframeEditor(element) or
80 # Google.
81 element.getAttribute?('g_editable') == 'true' or
82 element.ownerDocument?.body?.getAttribute('g_editable') == 'true'
83
84 isIframeEditor = (element) ->
85 return false unless element instanceof HTMLBodyElement
86 return \
87 # Etherpad.
88 element.id == 'innerdocbody' or
89 # XpressEditor.
90 (element.classList.contains('xe_content') and
91 element.classList.contains('editable')) or
92 # vBulletin.
93 element.classList.contains('wysiwyg')
94
95 isProperLink = (element) ->
96 # `.getAttribute` is used below instead of `.hasAttribute` to exclude `<a
97 # href="">`s used as buttons on some sites.
98 return element.getAttribute('href') and
99 (element instanceof HTMLAnchorElement or
100 element.ownerDocument instanceof XULDocument) and
101 not element.href.endsWith('#') and
102 not element.href.endsWith('#?') and
103 not element.href.startsWith('javascript:')
104
105 isTextInputElement = (element) ->
106 return (element instanceof HTMLInputElement and element.type in [
107 'text', 'search', 'tel', 'url', 'email', 'password', 'number'
108 ]) or
109 element instanceof HTMLTextAreaElement or
110 element instanceof XULTextBoxElement or
111 isContentEditable(element)
112
113 isTypingElement = (element) ->
114 return isTextInputElement(element) or
115 # `<select>` elements can also receive text input: You may type the
116 # text of an item to select it.
117 element instanceof HTMLSelectElement or
118 element instanceof XULMenuListElement
119
120
121
122 # Active/focused element helpers
123
124 # NOTE: In frame scripts, `document.activeElement` may be `null` when the page
125 # is loading. Therefore always check if anything was returned, such as:
126 #
127 # return unless activeElement = utils.getActiveElement(window)
128 getActiveElement = (window) ->
129 {activeElement} = window.document
130 return null unless activeElement
131 # If the active element is a frame, recurse into it. The easiest way to detect
132 # a frame that works both in browser UI and in web page content is to check
133 # for the presence of `.contentWindow`. However, in non-multi-process
134 # `<browser>` (sometimes `<xul:browser>`) elements have a `.contentWindow`
135 # pointing to the web page content `window`, which we don’t want to recurse
136 # into. `.localName` is `.nodeName` without `xul:` (if it exists). This seems
137 # to be the only way to detect such elements.
138 if activeElement.localName != 'browser' and activeElement.contentWindow
139 return getActiveElement(activeElement.contentWindow)
140 else
141 return activeElement
142
143 blurActiveElement = (window) ->
144 # Blurring a frame element also blurs any active elements inside it. Recursing
145 # into the frames and blurring the “real” active element directly would give
146 # focus to the `<body>` of its containing frame, while blurring the top-most
147 # frame gives focus to the top-most `<body>`. This allows to blur fancy text
148 # editors which use an `<iframe>` as their text area.
149 window.document.activeElement?.blur()
150
151 blurActiveBrowserElement = (vim) ->
152 # - Blurring in the next tick allows to pass `<escape>` to the location bar to
153 # reset it, for example.
154 # - Focusing the current browser afterwards allows to pass `<escape>` as well
155 # as unbound keys to the page. However, focusing the browser also triggers
156 # focus events on `document` and `window` in the current page. Many pages
157 # re-focus some text input on those events, making it impossible to blur
158 # those! Therefore we tell the frame script to suppress those events.
159 {window} = vim
160 activeElement = getActiveElement(window)
161 vim._send('browserRefocus')
162 nextTick(window, ->
163 activeElement.blur()
164 window.gBrowser.selectedBrowser.focus()
165 )
166
167 # Focus an element and tell Firefox that the focus happened because of a user
168 # action (not just because some random programmatic focus). `.FLAG_BYKEY` might
169 # look more appropriate, but it unconditionally selects all text, which
170 # `.FLAG_BYMOUSE` does not.
171 focusElement = (element, options = {}) ->
172 nsIFocusManager.setFocus(element, options.flag ? 'FLAG_BYMOUSE')
173 element.select?() if options.select
174
175 getFocusType = (element) -> switch
176 when isTypingElement(element)
177 'editable'
178 when isActivatable(element)
179 'activatable'
180 when isAdjustable(element)
181 'adjustable'
182 else
183 null
184
185
186
187 # Event helpers
188
189 listen = (element, eventName, listener, useCapture = true) ->
190 element.addEventListener(eventName, listener, useCapture)
191 module.onShutdown(->
192 element.removeEventListener(eventName, listener, useCapture)
193 )
194
195 listenOnce = (element, eventName, listener, useCapture = true) ->
196 fn = (event) ->
197 listener(event)
198 element.removeEventListener(eventName, fn, useCapture)
199 listen(element, eventName, fn, useCapture)
200
201 onRemoved = (window, element, fn) ->
202 mutationObserver = new window.MutationObserver((changes) ->
203 for change in changes then for removedElement in change.removedNodes
204 if removedElement == element
205 mutationObserver.disconnect()
206 fn()
207 return
208 )
209 mutationObserver.observe(element.parentNode, {childList: true})
210 module.onShutdown(mutationObserver.disconnect.bind(mutationObserver))
211
212 suppressEvent = (event) ->
213 event.preventDefault()
214 event.stopPropagation()
215
216 simulateMouseEvents = (element, sequenceType) ->
217 window = element.ownerGlobal
218 rect = element.getBoundingClientRect()
219
220 eventSequence = switch sequenceType
221 when 'click' then EVENTS_CLICK
222 when 'hover-start' then EVENTS_HOVER_START
223 when 'hover-end' then EVENTS_HOVER_END
224
225 for type in eventSequence
226 mouseEvent = new window.MouseEvent(type, {
227 # Let the event bubble in order to trigger delegated event listeners.
228 bubbles: type not in ['mouseenter', 'mouseleave']
229 # Make the event cancelable so that `<a href="#">` can be used as a
230 # JavaScript-powered button without scrolling to the top of the page.
231 cancelable: type not in ['mouseenter', 'mouseleave']
232 # These properties are just here for mimicing a real click as much as
233 # possible.
234 buttons: if type in EVENTS_CLICK then 1 else 0
235 detail: if type in EVENTS_CLICK then 1 else 0
236 view: window
237 # `page{X,Y}` are set automatically to the correct values when setting
238 # `client{X,Y}`. `{offset,layer,movement}{X,Y}` are not worth the trouble
239 # to set.
240 clientX: rect.left
241 clientY: rect.top
242 # To exactly calculate `screen{X,Y}` one has to to check where the web
243 # page content area is inside the browser chrome and go through all parent
244 # frames as well. This is good enough. YAGNI for now.
245 screenX: window.screenX + rect.left
246 screenY: window.screenY + rect.top
247 })
248 element.dispatchEvent(mouseEvent)
249
250 return
251
252
253
254 # DOM helpers
255
256 area = (element) ->
257 return element.clientWidth * element.clientHeight
258
259 containsDeep = (parent, element) ->
260 parentWindow = parent.ownerGlobal
261 elementWindow = element.ownerGlobal
262
263 # Owner windows might be missing when opening the devtools.
264 while elementWindow and parentWindow and
265 elementWindow != parentWindow and elementWindow.top != elementWindow
266 element = elementWindow.frameElement
267 elementWindow = element.ownerGlobal
268
269 return parent.contains(element)
270
271 createBox = (document, className = '', parent = null, text = null) ->
272 box = document.createElement('box')
273 box.className = "#{className} vimfx-box"
274 box.textContent = text if text?
275 parent.appendChild(box) if parent?
276 return box
277
278 injectTemporaryPopup = (document, contents) ->
279 popup = document.createElement('menupopup')
280 popup.appendChild(contents)
281 document.getElementById('mainPopupSet').appendChild(popup)
282 listenOnce(popup, 'popuphidden', popup.remove.bind(popup))
283 return popup
284
285 insertText = (input, value) ->
286 {selectionStart, selectionEnd} = input
287 input.value =
288 input.value[0...selectionStart] + value + input.value[selectionEnd..]
289 input.selectionStart = input.selectionEnd = selectionStart + value.length
290
291 querySelectorAllDeep = (window, selector) ->
292 elements = Array.from(window.document.querySelectorAll(selector))
293 for frame in window.frames
294 elements.push(querySelectorAllDeep(frame, selector)...)
295 return elements
296
297 scroll = (element, args) ->
298 {method, type, directions, amounts, properties, adjustment, smooth} = args
299 options = {}
300 for direction, index in directions
301 amount = amounts[index]
302 options[direction] = -Math.sign(amount) * adjustment + switch type
303 when 'lines' then amount
304 when 'pages' then amount * element[properties[index]]
305 when 'other' then Math.min(amount, element[properties[index]])
306 options.behavior = 'smooth' if smooth
307 element[method](options)
308
309 setAttributes = (element, attributes) ->
310 for attribute, value of attributes
311 element.setAttribute(attribute, value)
312 return
313
314 setHover = (element, hover) ->
315 method = if hover then 'addPseudoClassLock' else 'removePseudoClassLock'
316 while element.parentElement
317 nsIDomUtils[method](element, ':hover')
318 element = element.parentElement
319 return
320
321
322
323 # Language helpers
324
325 class Counter
326 constructor: ({start: @value = 0, @step = 1}) ->
327 tick: -> @value += @step
328
329 class EventEmitter
330 constructor: ->
331 @listeners = {}
332
333 on: (event, listener) ->
334 (@listeners[event] ?= []).push(listener)
335
336 emit: (event, data) ->
337 for listener in @listeners[event] ? []
338 listener(data)
339 return
340
341 has = (obj, prop) -> Object::hasOwnProperty.call(obj, prop)
342
343 # Check if `search` exists in `string` (case insensitively). Returns `false` if
344 # `string` doesn’t exist or isn’t a string, such as `<SVG element>.className`.
345 includes = (string, search) ->
346 return false unless typeof string == 'string'
347 return string.toLowerCase().includes(search)
348
349
350 nextTick = (window, fn) -> window.setTimeout(fn, 0)
351
352 regexEscape = (s) -> s.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
353
354 removeDuplicates = (array) -> Array.from(new Set(array))
355
356 # Remove duplicate characters from string (case insensitive).
357 removeDuplicateCharacters = (str) ->
358 return removeDuplicates( str.toLowerCase().split('') ).join('')
359
360 # Calls `fn` repeatedly, with at least `interval` ms between each call.
361 setInterval = (window, interval, fn) ->
362 stopped = false
363 currentIntervalId = null
364 next = ->
365 return if stopped
366 currentIntervalId = window.setTimeout((-> fn(next)), interval)
367 clearInterval = ->
368 stopped = true
369 window.clearTimeout(currentIntervalId)
370 next()
371 return clearInterval
372
373
374
375 # Misc helpers
376
377 formatError = (error) ->
378 stack = String(error.stack?.formattedStack ? error.stack ? '')
379 .split('\n')
380 .filter((line) -> line.includes('.xpi!'))
381 .map((line) -> ' ' + line.replace(/(?:\/<)*@.+\.xpi!/g, '@'))
382 .join('\n')
383 return "#{error}\n#{stack}"
384
385 getCurrentLocation = ->
386 window = getCurrentWindow()
387 return new window.URL(window.gBrowser.selectedBrowser.currentURI.spec)
388
389 getCurrentWindow = -> nsIWindowMediator.getMostRecentWindow('navigator:browser')
390
391 hasEventListeners = (element, type) ->
392 for listener in nsIEventListenerService.getListenerInfoFor(element)
393 if listener.listenerObject and listener.type == type
394 return true
395 return false
396
397 loadCss = (name) ->
398 uri = Services.io.newURI("chrome://vimfx/skin/#{name}.css", null, null)
399 method = nsIStyleSheetService.AUTHOR_SHEET
400 unless nsIStyleSheetService.sheetRegistered(uri, method)
401 nsIStyleSheetService.loadAndRegisterSheet(uri, method)
402 module.onShutdown(->
403 nsIStyleSheetService.unregisterSheet(uri, method)
404 )
405
406 observe = (topic, observer) ->
407 observer = {observe: observer} if typeof observer == 'function'
408 Services.obs.addObserver(observer, topic, false)
409 module.onShutdown(->
410 Services.obs.removeObserver(observer, topic, false)
411 )
412
413 openPopup = (popup) ->
414 window = popup.ownerGlobal
415 # Show the popup so it gets a height and width.
416 popup.openPopupAtScreen(0, 0)
417 # Center the popup inside the window.
418 popup.moveTo(
419 window.screenX + window.outerWidth / 2 - popup.clientWidth / 2,
420 window.screenY + window.outerHeight / 2 - popup.clientHeight / 2
421 )
422
423 openTab = (window, url, options) ->
424 {gBrowser} = window
425 window.TreeStyleTabService?.readyToOpenChildTab(gBrowser.selectedTab)
426 gBrowser.loadOneTab(url, options)
427
428 writeToClipboard = (text) -> nsIClipboardHelper.copyString(text)
429
430
431
432 module.exports = {
433 isActivatable
434 isAdjustable
435 isContentEditable
436 isProperLink
437 isTextInputElement
438 isTypingElement
439
440 getActiveElement
441 blurActiveElement
442 blurActiveBrowserElement
443 focusElement
444 getFocusType
445
446 listen
447 listenOnce
448 onRemoved
449 suppressEvent
450 simulateMouseEvents
451
452 area
453 containsDeep
454 createBox
455 injectTemporaryPopup
456 insertText
457 querySelectorAllDeep
458 scroll
459 setAttributes
460 setHover
461
462 Counter
463 EventEmitter
464 has
465 includes
466 nextTick
467 regexEscape
468 removeDuplicates
469 removeDuplicateCharacters
470 setInterval
471
472 formatError
473 getCurrentLocation
474 getCurrentWindow
475 hasEventListeners
476 loadCss
477 observe
478 openPopup
479 openTab
480 writeToClipboard
481 }
Imprint / Impressum