]> git.gir.st - VimFx.git/blob - extension/lib/utils.coffee
Add partial support for the wasavi extension
[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' or
83 # Codeacademy terminals.
84 element.classList?.contains('real-terminal')
85
86 isIframeEditor = (element) ->
87 return false unless element instanceof HTMLBodyElement
88 return \
89 # Etherpad.
90 element.id == 'innerdocbody' or
91 # XpressEditor.
92 (element.classList.contains('xe_content') and
93 element.classList.contains('editable')) or
94 # vBulletin.
95 element.classList.contains('wysiwyg') or
96 # The wasavi extension.
97 element.hasAttribute('data-wasavi-state')
98
99 isProperLink = (element) ->
100 # `.getAttribute` is used below instead of `.hasAttribute` to exclude `<a
101 # href="">`s used as buttons on some sites.
102 return element.getAttribute('href') and
103 (element instanceof HTMLAnchorElement or
104 element.ownerDocument instanceof XULDocument) and
105 not element.href.endsWith('#') and
106 not element.href.endsWith('#?') and
107 not element.href.startsWith('javascript:')
108
109 isTextInputElement = (element) ->
110 return (element instanceof HTMLInputElement and element.type in [
111 'text', 'search', 'tel', 'url', 'email', 'password', 'number'
112 ]) or
113 element instanceof HTMLTextAreaElement or
114 element instanceof XULTextBoxElement or
115 isContentEditable(element)
116
117 isTypingElement = (element) ->
118 return isTextInputElement(element) or
119 # `<select>` elements can also receive text input: You may type the
120 # text of an item to select it.
121 element instanceof HTMLSelectElement or
122 element instanceof XULMenuListElement
123
124
125
126 # Active/focused element helpers
127
128 # NOTE: In frame scripts, `document.activeElement` may be `null` when the page
129 # is loading. Therefore always check if anything was returned, such as:
130 #
131 # return unless activeElement = utils.getActiveElement(window)
132 getActiveElement = (window) ->
133 {activeElement} = window.document
134 return null unless activeElement
135 # If the active element is a frame, recurse into it. The easiest way to detect
136 # a frame that works both in browser UI and in web page content is to check
137 # for the presence of `.contentWindow`. However, in non-multi-process
138 # `<browser>` (sometimes `<xul:browser>`) elements have a `.contentWindow`
139 # pointing to the web page content `window`, which we don’t want to recurse
140 # into. `.localName` is `.nodeName` without `xul:` (if it exists). This seems
141 # to be the only way to detect such elements.
142 if activeElement.localName != 'browser' and activeElement.contentWindow
143 return getActiveElement(activeElement.contentWindow)
144 else
145 return activeElement
146
147 blurActiveElement = (window) ->
148 # Blurring a frame element also blurs any active elements inside it. Recursing
149 # into the frames and blurring the “real” active element directly would give
150 # focus to the `<body>` of its containing frame, while blurring the top-most
151 # frame gives focus to the top-most `<body>`. This allows to blur fancy text
152 # editors which use an `<iframe>` as their text area.
153 window.document.activeElement?.blur()
154
155 blurActiveBrowserElement = (vim) ->
156 # - Blurring in the next tick allows to pass `<escape>` to the location bar to
157 # reset it, for example.
158 # - Focusing the current browser afterwards allows to pass `<escape>` as well
159 # as unbound keys to the page. However, focusing the browser also triggers
160 # focus events on `document` and `window` in the current page. Many pages
161 # re-focus some text input on those events, making it impossible to blur
162 # those! Therefore we tell the frame script to suppress those events.
163 {window} = vim
164 activeElement = getActiveElement(window)
165 vim._send('browserRefocus')
166 nextTick(window, ->
167 activeElement.blur()
168 window.gBrowser.selectedBrowser.focus()
169 )
170
171 # Focus an element and tell Firefox that the focus happened because of a user
172 # action (not just because some random programmatic focus). `.FLAG_BYKEY` might
173 # look more appropriate, but it unconditionally selects all text, which
174 # `.FLAG_BYMOUSE` does not.
175 focusElement = (element, options = {}) ->
176 nsIFocusManager.setFocus(element, options.flag ? 'FLAG_BYMOUSE')
177 element.select?() if options.select
178
179 getFocusType = (element) -> switch
180 when isTypingElement(element)
181 'editable'
182 when isActivatable(element)
183 'activatable'
184 when isAdjustable(element)
185 'adjustable'
186 else
187 null
188
189
190
191 # Event helpers
192
193 listen = (element, eventName, listener, useCapture = true) ->
194 element.addEventListener(eventName, listener, useCapture)
195 module.onShutdown(->
196 element.removeEventListener(eventName, listener, useCapture)
197 )
198
199 listenOnce = (element, eventName, listener, useCapture = true) ->
200 fn = (event) ->
201 listener(event)
202 element.removeEventListener(eventName, fn, useCapture)
203 listen(element, eventName, fn, useCapture)
204
205 onRemoved = (window, element, fn) ->
206 mutationObserver = new window.MutationObserver((changes) ->
207 for change in changes then for removedElement in change.removedNodes
208 if removedElement == element
209 mutationObserver.disconnect()
210 fn()
211 return
212 )
213 mutationObserver.observe(element.parentNode, {childList: true})
214 module.onShutdown(mutationObserver.disconnect.bind(mutationObserver))
215
216 suppressEvent = (event) ->
217 event.preventDefault()
218 event.stopPropagation()
219
220 simulateMouseEvents = (element, sequenceType) ->
221 window = element.ownerGlobal
222 rect = element.getBoundingClientRect()
223
224 eventSequence = switch sequenceType
225 when 'click'
226 EVENTS_CLICK
227 when 'hover-start'
228 EVENTS_HOVER_START
229 when 'hover-end'
230 EVENTS_HOVER_END
231
232 for type in eventSequence
233 buttonNum = if type in EVENTS_CLICK then 1 else 0
234 mouseEvent = new window.MouseEvent(type, {
235 # Let the event bubble in order to trigger delegated event listeners.
236 bubbles: type not in ['mouseenter', 'mouseleave']
237 # Make the event cancelable so that `<a href="#">` can be used as a
238 # JavaScript-powered button without scrolling to the top of the page.
239 cancelable: type not in ['mouseenter', 'mouseleave']
240 # These properties are just here for mimicing a real click as much as
241 # possible.
242 buttons: buttonNum
243 detail: buttonNum
244 view: window
245 # `page{X,Y}` are set automatically to the correct values when setting
246 # `client{X,Y}`. `{offset,layer,movement}{X,Y}` are not worth the trouble
247 # to set.
248 clientX: rect.left
249 clientY: rect.top
250 # To exactly calculate `screen{X,Y}` one has to to check where the web
251 # page content area is inside the browser chrome and go through all parent
252 # frames as well. This is good enough. YAGNI for now.
253 screenX: window.screenX + rect.left
254 screenY: window.screenY + rect.top
255 })
256 element.dispatchEvent(mouseEvent)
257
258 return
259
260
261
262 # DOM helpers
263
264 area = (element) ->
265 return element.clientWidth * element.clientHeight
266
267 containsDeep = (parent, element) ->
268 parentWindow = parent.ownerGlobal
269 elementWindow = element.ownerGlobal
270
271 # Owner windows might be missing when opening the devtools.
272 while elementWindow and parentWindow and
273 elementWindow != parentWindow and elementWindow.top != elementWindow
274 element = elementWindow.frameElement
275 elementWindow = element.ownerGlobal
276
277 return parent.contains(element)
278
279 createBox = (document, className = '', parent = null, text = null) ->
280 box = document.createElement('box')
281 box.className = "#{className} vimfx-box"
282 box.textContent = text if text?
283 parent.appendChild(box) if parent?
284 return box
285
286 # Returns the minimum of `element.clientHeight` and the height of the viewport,
287 # taking fixed headers and footers into account. Adapted from Firefox’s source
288 # code for `<space>` scrolling (which is where the arbitrary constants below
289 # come from).
290 #
291 # coffeelint: disable=max_line_length
292 # <https://hg.mozilla.org/mozilla-central/file/4d75bd6fd234/layout/generic/nsGfxScrollFrame.cpp#l3829>
293 # coffeelint: enable=max_line_length
294 getViewportCappedClientHeight = (element) ->
295 window = element.ownerGlobal
296 viewport = getWindowViewport(window)
297 headerBottom = viewport.top
298 footerTop = viewport.bottom
299 maxHeight = viewport.height / 3
300 minWidth = Math.min(viewport.width / 2, 800)
301
302 # Restricting the candidates for headers and footers to the most likely set of
303 # elements results in a noticeable performance boost.
304 candidates = window.document.querySelectorAll(
305 'div, ul, nav, header, footer, section'
306 )
307
308 for candidate in candidates
309 rect = candidate.getBoundingClientRect()
310 continue unless rect.height <= maxHeight and rect.width >= minWidth
311 # Checking for `position: fixed;` is the absolutely most expensive
312 # operation, so that is done last.
313 switch
314 when rect.top <= headerBottom and rect.bottom > headerBottom and
315 isPositionFixed(candidate)
316 headerBottom = rect.bottom
317 when rect.bottom >= footerTop and rect.top < footerTop and
318 isPositionFixed(candidate)
319 footerTop = rect.top
320
321 return Math.min(element.clientHeight, footerTop - headerBottom)
322
323 getWindowViewport = (window) ->
324 {
325 clientWidth, clientHeight # Viewport size excluding scrollbars, usually.
326 scrollWidth, scrollHeight
327 } = window.document.documentElement
328 {innerWidth, innerHeight} = window # Viewport size including scrollbars.
329 # We don’t want markers to cover the scrollbars, so we should use
330 # `clientWidth` and `clientHeight`. However, when there are no scrollbars
331 # those might be too small. Then we use `innerWidth` and `innerHeight`.
332 width = if scrollWidth > innerWidth then clientWidth else innerWidth
333 height = if scrollHeight > innerHeight then clientHeight else innerHeight
334 return {
335 left: 0
336 top: 0
337 right: width
338 bottom: height
339 width
340 height
341 }
342
343 injectTemporaryPopup = (document, contents) ->
344 popup = document.createElement('menupopup')
345 popup.appendChild(contents)
346 document.getElementById('mainPopupSet').appendChild(popup)
347 listenOnce(popup, 'popuphidden', popup.remove.bind(popup))
348 return popup
349
350 insertText = (input, value) ->
351 {selectionStart, selectionEnd} = input
352 input.value =
353 input.value[0...selectionStart] + value + input.value[selectionEnd..]
354 input.selectionStart = input.selectionEnd = selectionStart + value.length
355
356 isPositionFixed = (element) ->
357 computedStyle = element.ownerGlobal.getComputedStyle(element)
358 return computedStyle?.getPropertyValue('position') == 'fixed'
359
360 querySelectorAllDeep = (window, selector) ->
361 elements = Array.from(window.document.querySelectorAll(selector))
362 for frame in window.frames
363 elements.push(querySelectorAllDeep(frame, selector)...)
364 return elements
365
366 scroll = (element, args) ->
367 {method, type, directions, amounts, properties, adjustment, smooth} = args
368 options = {}
369 for direction, index in directions
370 amount = amounts[index]
371 options[direction] = -Math.sign(amount) * adjustment + switch type
372 when 'lines'
373 amount
374 when 'pages'
375 amount *
376 if properties[index] == 'clientHeight'
377 getViewportCappedClientHeight(element)
378 else
379 element[properties[index]]
380 when 'other'
381 Math.min(amount, element[properties[index]])
382 options.behavior = 'smooth' if smooth
383 element[method](options)
384
385 setAttributes = (element, attributes) ->
386 for attribute, value of attributes
387 element.setAttribute(attribute, value)
388 return
389
390 setHover = (element, hover) ->
391 method = if hover then 'addPseudoClassLock' else 'removePseudoClassLock'
392 while element.parentElement
393 nsIDomUtils[method](element, ':hover')
394 element = element.parentElement
395 return
396
397
398
399 # Language helpers
400
401 class Counter
402 constructor: ({start: @value = 0, @step = 1}) ->
403 tick: -> @value += @step
404
405 class EventEmitter
406 constructor: ->
407 @listeners = {}
408
409 on: (event, listener) ->
410 (@listeners[event] ?= new Set()).add(listener)
411
412 off: (event, listener) ->
413 @listeners[event]?.delete(listener)
414
415 emit: (event, data) ->
416 @listeners[event]?.forEach((listener) ->
417 listener(data)
418 )
419
420 has = (obj, prop) -> Object::hasOwnProperty.call(obj, prop)
421
422 # Check if `search` exists in `string` (case insensitively). Returns `false` if
423 # `string` doesn’t exist or isn’t a string, such as `<SVG element>.className`.
424 includes = (string, search) ->
425 return false unless typeof string == 'string'
426 return string.toLowerCase().includes(search)
427
428
429 nextTick = (window, fn) -> window.setTimeout((-> fn()) , 0)
430
431 regexEscape = (s) -> s.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
432
433 removeDuplicates = (array) -> Array.from(new Set(array))
434
435 # Remove duplicate characters from string (case insensitive).
436 removeDuplicateCharacters = (str) ->
437 return removeDuplicates( str.toLowerCase().split('') ).join('')
438
439 # Calls `fn` repeatedly, with at least `interval` ms between each call.
440 interval = (window, interval, fn) ->
441 stopped = false
442 currentIntervalId = null
443 next = ->
444 return if stopped
445 currentIntervalId = window.setTimeout((-> fn(next)), interval)
446 clearInterval = ->
447 stopped = true
448 window.clearTimeout(currentIntervalId)
449 next()
450 return clearInterval
451
452
453
454 # Misc helpers
455
456 formatError = (error) ->
457 stack = String(error.stack?.formattedStack ? error.stack ? '')
458 .split('\n')
459 .filter((line) -> line.includes('.xpi!'))
460 .map((line) -> ' ' + line.replace(/(?:\/<)*@.+\.xpi!/g, '@'))
461 .join('\n')
462 return "#{error}\n#{stack}"
463
464 getCurrentLocation = ->
465 window = getCurrentWindow()
466 return new window.URL(window.gBrowser.selectedBrowser.currentURI.spec)
467
468 getCurrentWindow = -> nsIWindowMediator.getMostRecentWindow('navigator:browser')
469
470 hasEventListeners = (element, type) ->
471 for listener in nsIEventListenerService.getListenerInfoFor(element)
472 if listener.listenerObject and listener.type == type
473 return true
474 return false
475
476 loadCss = (uriString) ->
477 uri = Services.io.newURI(uriString, null, null)
478 method = nsIStyleSheetService.AUTHOR_SHEET
479 unless nsIStyleSheetService.sheetRegistered(uri, method)
480 nsIStyleSheetService.loadAndRegisterSheet(uri, method)
481 module.onShutdown(->
482 nsIStyleSheetService.unregisterSheet(uri, method)
483 )
484
485 observe = (topic, observer) ->
486 observer = {observe: observer} if typeof observer == 'function'
487 Services.obs.addObserver(observer, topic, false)
488 module.onShutdown(->
489 Services.obs.removeObserver(observer, topic, false)
490 )
491
492 openPopup = (popup) ->
493 window = popup.ownerGlobal
494 # Show the popup so it gets a height and width.
495 popup.openPopupAtScreen(0, 0)
496 # Center the popup inside the window.
497 popup.moveTo(
498 window.screenX + window.outerWidth / 2 - popup.clientWidth / 2,
499 window.screenY + window.outerHeight / 2 - popup.clientHeight / 2
500 )
501
502 openTab = (window, url, options) ->
503 {gBrowser} = window
504 window.TreeStyleTabService?.readyToOpenChildTab(gBrowser.selectedTab)
505 gBrowser.loadOneTab(url, options)
506
507 writeToClipboard = (text) -> nsIClipboardHelper.copyString(text)
508
509
510
511 module.exports = {
512 isActivatable
513 isAdjustable
514 isContentEditable
515 isProperLink
516 isTextInputElement
517 isTypingElement
518
519 getActiveElement
520 blurActiveElement
521 blurActiveBrowserElement
522 focusElement
523 getFocusType
524
525 listen
526 listenOnce
527 onRemoved
528 suppressEvent
529 simulateMouseEvents
530
531 area
532 containsDeep
533 createBox
534 getViewportCappedClientHeight
535 getWindowViewport
536 injectTemporaryPopup
537 insertText
538 isPositionFixed
539 querySelectorAllDeep
540 scroll
541 setAttributes
542 setHover
543
544 Counter
545 EventEmitter
546 has
547 includes
548 nextTick
549 regexEscape
550 removeDuplicates
551 removeDuplicateCharacters
552 interval
553
554 formatError
555 getCurrentLocation
556 getCurrentWindow
557 hasEventListeners
558 loadCss
559 observe
560 openPopup
561 openTab
562 writeToClipboard
563 }
Imprint / Impressum