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