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