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