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