]> git.gir.st - VimFx.git/blob - extension/lib/utils.coffee
Prevent `vimfx.addOptionOverrides` from crashing on startup
[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 # Copyright Alan Wu 2016.
6 #
7 # This file is part of VimFx.
8 #
9 # VimFx is free software: you can redistribute it and/or modify
10 # it under the terms of the GNU General Public License as published by
11 # the Free Software Foundation, either version 3 of the License, or
12 # (at your option) any later version.
13 #
14 # VimFx is distributed in the hope that it will be useful,
15 # but WITHOUT ANY WARRANTY; without even the implied warranty of
16 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 # GNU General Public License for more details.
18 #
19 # You should have received a copy of the GNU General Public License
20 # along with VimFx. If not, see <http://www.gnu.org/licenses/>.
21 ###
22
23 # This file contains lots of different helper functions.
24
25 {OS} = Components.utils.import('resource://gre/modules/osfile.jsm', {})
26
27 nsIClipboardHelper = Cc['@mozilla.org/widget/clipboardhelper;1']
28 .getService(Ci.nsIClipboardHelper)
29 nsIDomUtils = Cc['@mozilla.org/inspector/dom-utils;1']
30 .getService(Ci.inIDOMUtils)
31 nsIEventListenerService = Cc['@mozilla.org/eventlistenerservice;1']
32 .getService(Ci.nsIEventListenerService)
33 nsIFocusManager = Cc['@mozilla.org/focus-manager;1']
34 .getService(Ci.nsIFocusManager)
35 nsIStyleSheetService = Cc['@mozilla.org/content/style-sheet-service;1']
36 .getService(Ci.nsIStyleSheetService)
37 nsIWindowMediator = Cc['@mozilla.org/appshell/window-mediator;1']
38 .getService(Ci.nsIWindowMediator)
39
40 # For XUL, `instanceof` checks are often better than `.localName` checks,
41 # because some of the below interfaces are extended by many elements.
42 XULDocument = Ci.nsIDOMXULDocument
43 XULButtonElement = Ci.nsIDOMXULButtonElement
44 XULControlElement = Ci.nsIDOMXULControlElement
45 XULMenuListElement = Ci.nsIDOMXULMenuListElement
46 XULTextBoxElement = Ci.nsIDOMXULTextBoxElement
47
48 # Full chains of events for different mouse actions. Note: 'click' is fired
49 # by Firefox automatically after 'mousedown' and 'mouseup'. Similarly,
50 # 'command' is fired automatically after 'click' on xul pages.
51 EVENTS_CLICK = ['mousedown', 'mouseup']
52 EVENTS_CLICK_XUL = ['click']
53 EVENTS_HOVER_START = ['mouseover', 'mouseenter', 'mousemove']
54 EVENTS_HOVER_END = ['mouseout', 'mouseleave']
55
56
57
58 # Element classification helpers
59
60 isActivatable = (element) ->
61 return element.localName in ['a', 'button'] or
62 (element.localName == 'input' and element.type in [
63 'button', 'submit', 'reset', 'image'
64 ]) or
65 element instanceof XULButtonElement
66
67 isAdjustable = (element) ->
68 return element.localName == 'input' and element.type in [
69 'checkbox', 'radio', 'file', 'color'
70 'date', 'time', 'datetime', 'datetime-local', 'month', 'week'
71 ] or
72 element.localName in ['video', 'audio', 'embed', 'object'] or
73 element instanceof XULControlElement or
74 # Custom video players.
75 includes(element.className, 'video') or
76 includes(element.className, 'player') or
77 # Youtube special case.
78 element.classList?.contains('ytp-button') or
79 # Allow navigating object inspection trees in th devtools with the
80 # arrow keys, even if the arrow keys are used as VimFx shortcuts.
81 isDevtoolsElement(element)
82
83 isContentEditable = (element) ->
84 return element.isContentEditable or
85 isIframeEditor(element) or
86 # Google.
87 element.getAttribute?('g_editable') == 'true' or
88 element.ownerDocument?.body?.getAttribute?('g_editable') == 'true' or
89 # Codeacademy terminals.
90 element.classList?.contains('real-terminal')
91
92 isDevtoolsElement = (element) ->
93 return false unless element.ownerGlobal
94 return Array.some(element.ownerGlobal.top.frames, isDevtoolsWindow)
95
96 isDevtoolsWindow = (window) ->
97 return window.location?.href in [
98 'about:devtools-toolbox'
99 'chrome://devtools/content/framework/toolbox.xul'
100 ]
101
102 isFocusable = (element) ->
103 return element.tabIndex > -1 and
104 not (element.localName?.endsWith?('box') and
105 element.localName != 'checkbox') and
106 not (element.localName == 'toolbarbutton' and
107 element.parentNode?.localName == 'toolbarbutton') and
108 element.localName not in ['tabs', 'menuitem', 'menuseparator']
109
110 isIframeEditor = (element) ->
111 return false unless element.localName == 'body'
112 return \
113 # Etherpad.
114 element.id == 'innerdocbody' or
115 # XpressEditor.
116 (element.classList?.contains('xe_content') and
117 element.classList?.contains('editable')) or
118 # vBulletin.
119 element.classList?.contains('wysiwyg') or
120 # TYPO3 CMS.
121 element.classList?.contains('htmlarea-content-body') or
122 # The wasavi extension.
123 element.hasAttribute?('data-wasavi-state')
124
125 isIgnoreModeFocusType = (element) ->
126 return \
127 # The wasavi extension.
128 element.hasAttribute?('data-wasavi-state') or
129 element.closest?('#wasavi_container') or
130 # CodeMirror in Vim mode.
131 (element.localName == 'textarea' and
132 element.closest?('.CodeMirror') and _hasVimEventListener(element))
133
134 # CodeMirror’s Vim mode is really sucky to detect. The only way seems to be to
135 # check if the there are any event listener functions with Vim-y words in them.
136 _hasVimEventListener = (element) ->
137 for listener in nsIEventListenerService.getListenerInfoFor(element)
138 if listener.listenerObject and
139 /\bvim\b|insertmode/i.test(String(listener.listenerObject))
140 return true
141 return false
142
143 isProperLink = (element) ->
144 # `.getAttribute` is used below instead of `.hasAttribute` to exclude `<a
145 # href="">`s used as buttons on some sites.
146 return element.getAttribute?('href') and
147 (element.localName == 'a' or
148 element.ownerDocument instanceof XULDocument) and
149 not element.href?.endsWith?('#') and
150 not element.href?.endsWith?('#?') and
151 not element.href?.startsWith?('javascript:')
152
153 isTextInputElement = (element) ->
154 return (element.localName == 'input' and element.type in [
155 'text', 'search', 'tel', 'url', 'email', 'password', 'number'
156 ]) or
157 element.localName == 'textarea' or
158 element instanceof XULTextBoxElement or
159 isContentEditable(element)
160
161 isTypingElement = (element) ->
162 return isTextInputElement(element) or
163 # `<select>` elements can also receive text input: You may type the
164 # text of an item to select it.
165 element.localName == 'select' or
166 element instanceof XULMenuListElement
167
168
169
170 # Active/focused element helpers
171
172 # NOTE: In frame scripts, `document.activeElement` may be `null` when the page
173 # is loading. Therefore always check if anything was returned, such as:
174 #
175 # return unless activeElement = utils.getActiveElement(window)
176 getActiveElement = (window) ->
177 {activeElement} = window.document
178 return null unless activeElement
179 # If the active element is a frame, recurse into it. The easiest way to detect
180 # a frame that works both in browser UI and in web page content is to check
181 # for the presence of `.contentWindow`. However, in non-multi-process,
182 # `<browser>` (sometimes `<xul:browser>`) elements have a `.contentWindow`
183 # pointing to the web page content `window`, which we don’t want to recurse
184 # into. The problem is that there are _some_ `<browser>`s which we _want_ to
185 # recurse into, such as the sidebar (for instance the history sidebar), and
186 # dialogs in `about:preferences`. Checking the `contextmenu` attribute seems
187 # to be a reliable test, catching both the main tab `<browser>`s and bookmarks
188 # opened in the sidebar.
189 if (activeElement.localName == 'browser' and
190 activeElement.getAttribute?('contextmenu') == 'contentAreaContextMenu') or
191 not activeElement.contentWindow
192 return activeElement
193 else
194 return getActiveElement(activeElement.contentWindow)
195
196 blurActiveElement = (window) ->
197 # Blurring a frame element also blurs any active elements inside it. Recursing
198 # into the frames and blurring the “real” active element directly would give
199 # focus to the `<body>` of its containing frame, while blurring the top-most
200 # frame gives focus to the top-most `<body>`. This allows to blur fancy text
201 # editors which use an `<iframe>` as their text area.
202 window.document.activeElement?.blur()
203
204 blurActiveBrowserElement = (vim) ->
205 # - Blurring in the next tick allows to pass `<escape>` to the location bar to
206 # reset it, for example.
207 # - Focusing the current browser afterwards allows to pass `<escape>` as well
208 # as unbound keys to the page. However, focusing the browser also triggers
209 # focus events on `document` and `window` in the current page. Many pages
210 # re-focus some text input on those events, making it impossible to blur
211 # those! Therefore we tell the frame script to suppress those events.
212 {window} = vim
213 activeElement = getActiveElement(window)
214 vim._send('browserRefocus')
215 nextTick(window, ->
216 activeElement.blur()
217 window.gBrowser.selectedBrowser.focus()
218 )
219
220 # Focus an element and tell Firefox that the focus happened because of a user
221 # action (not just because some random programmatic focus). `.FLAG_BYKEY` might
222 # look more appropriate, but it unconditionally selects all text, which
223 # `.FLAG_BYMOUSE` does not.
224 focusElement = (element, options = {}) ->
225 nsIFocusManager.setFocus(element, options.flag ? 'FLAG_BYMOUSE')
226 element.select?() if options.select
227
228 getFocusType = (element) -> switch
229 when isIgnoreModeFocusType(element)
230 'ignore'
231 when isTypingElement(element)
232 if element.closest?('findbar') then 'findbar' else 'editable'
233 when isActivatable(element)
234 'activatable'
235 when isAdjustable(element)
236 'adjustable'
237 else
238 'none'
239
240
241
242 # Event helpers
243
244 listen = (element, eventName, listener, useCapture = true) ->
245 element.addEventListener(eventName, listener, useCapture)
246 module.onShutdown(->
247 element.removeEventListener(eventName, listener, useCapture)
248 )
249
250 listenOnce = (element, eventName, listener, useCapture = true) ->
251 fn = (event) ->
252 listener(event)
253 element.removeEventListener(eventName, fn, useCapture)
254 listen(element, eventName, fn, useCapture)
255
256 onRemoved = (element, fn) ->
257 window = element.ownerGlobal
258
259 disconnected = false
260 disconnect = ->
261 return if disconnected
262 disconnected = true
263 mutationObserver.disconnect() unless Cu.isDeadWrapper(mutationObserver)
264
265 mutationObserver = new window.MutationObserver((changes) ->
266 for change in changes then for removedElement in change.removedNodes
267 if removedElement.contains?(element)
268 disconnect()
269 fn()
270 return
271 )
272 mutationObserver.observe(window.document.documentElement, {
273 childList: true
274 subtree: true
275 })
276 module.onShutdown(disconnect)
277
278 return disconnect
279
280 suppressEvent = (event) ->
281 event.preventDefault()
282 event.stopPropagation()
283
284 simulateMouseEvents = (element, sequence) ->
285 window = element.ownerGlobal
286 rect = element.getBoundingClientRect()
287
288 eventSequence = switch sequence
289 when 'click'
290 EVENTS_CLICK
291 when 'click-xul'
292 EVENTS_CLICK_XUL
293 when 'hover-start'
294 EVENTS_HOVER_START
295 when 'hover-end'
296 EVENTS_HOVER_END
297 else
298 sequence
299
300 for type in eventSequence
301 buttonNum = if type in EVENTS_CLICK then 1 else 0
302 mouseEvent = new window.MouseEvent(type, {
303 # Let the event bubble in order to trigger delegated event listeners.
304 bubbles: type not in ['mouseenter', 'mouseleave']
305 # Make the event cancelable so that `<a href="#">` can be used as a
306 # JavaScript-powered button without scrolling to the top of the page.
307 cancelable: type not in ['mouseenter', 'mouseleave']
308 # These properties are just here for mimicing a real click as much as
309 # possible.
310 buttons: buttonNum
311 detail: buttonNum
312 view: window
313 # `page{X,Y}` are set automatically to the correct values when setting
314 # `client{X,Y}`. `{offset,layer,movement}{X,Y}` are not worth the trouble
315 # to set.
316 clientX: rect.left
317 clientY: rect.top
318 # To exactly calculate `screen{X,Y}` one has to to check where the web
319 # page content area is inside the browser chrome and go through all parent
320 # frames as well. This is good enough. YAGNI for now.
321 screenX: window.screenX + rect.left
322 screenY: window.screenY + rect.top
323 })
324 if type == 'mousemove'
325 # If the below technique is used for this event, the “URL popup” (shown
326 # when hovering or focusing links) does not appear.
327 element.dispatchEvent(mouseEvent)
328 else
329 # The last `true` below marks the event as trusted, which some APIs
330 # require, such as `requestFullscreen()`.
331 # (`element.dispatchEvent(mouseEvent)` is not able to do this.)
332 window
333 .QueryInterface(Ci.nsIInterfaceRequestor)
334 .getInterface(Ci.nsIDOMWindowUtils)
335 .dispatchDOMEventViaPresShell(element, mouseEvent, true)
336
337 return
338
339
340
341 # DOM helpers
342
343 area = (element) ->
344 return element.clientWidth * element.clientHeight
345
346 checkElementOrAncestor = (element, fn) ->
347 window = element.ownerGlobal
348 while element.parentElement
349 return true if fn(element)
350 element = element.parentElement
351 return false
352
353 clearSelectionDeep = (window) ->
354 # The selection might be `null` in hidden frames.
355 selection = window.getSelection()
356 selection?.removeAllRanges()
357 for frame in window.frames
358 clearSelectionDeep(frame)
359 # Allow parents to re-gain control of text selection.
360 frame.frameElement.blur()
361 return
362
363 containsDeep = (parent, element) ->
364 parentWindow = parent.ownerGlobal
365 elementWindow = element.ownerGlobal
366
367 # Owner windows might be missing when opening the devtools.
368 while elementWindow and parentWindow and
369 elementWindow != parentWindow and elementWindow.top != elementWindow
370 element = elementWindow.frameElement
371 elementWindow = element.ownerGlobal
372
373 return parent.contains(element)
374
375 createBox = (document, className = '', parent = null, text = null) ->
376 box = document.createElement('box')
377 box.className = "#{className} vimfx-box"
378 box.textContent = text if text?
379 parent.appendChild(box) if parent?
380 return box
381
382 # In quirks mode (when the page lacks a doctype), such as on Hackernews,
383 # `<body>` is considered the root element rather than `<html>`.
384 getRootElement = (document) ->
385 if document.compatMode == 'BackCompat' and document.body?
386 return document.body
387 else
388 return document.documentElement
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 isNonEmptyTextNode = (node) ->
407 return node.nodeType == 3 and node.data.trim() != ''
408
409 isPositionFixed = (element) ->
410 computedStyle = element.ownerGlobal.getComputedStyle(element)
411 return computedStyle?.getPropertyValue('position') == 'fixed'
412
413 querySelectorAllDeep = (window, selector) ->
414 elements = Array.from(window.document.querySelectorAll(selector))
415 for frame in window.frames
416 elements.push(querySelectorAllDeep(frame, selector)...)
417 return elements
418
419 setAttributes = (element, attributes) ->
420 for attribute, value of attributes
421 element.setAttribute(attribute, value)
422 return
423
424 setHover = (element, hover) ->
425 method = if hover then 'addPseudoClassLock' else 'removePseudoClassLock'
426 while element.parentElement
427 nsIDomUtils[method](element, ':hover')
428 element = element.parentElement
429 return
430
431
432
433 # Language helpers
434
435 class Counter
436 constructor: ({start: @value = 0, @step = 1}) ->
437 tick: -> @value += @step
438
439 class EventEmitter
440 constructor: ->
441 @listeners = {}
442
443 on: (event, listener) ->
444 (@listeners[event] ?= new Set()).add(listener)
445
446 off: (event, listener) ->
447 @listeners[event]?.delete(listener)
448
449 emit: (event, data) ->
450 @listeners[event]?.forEach((listener) ->
451 listener(data)
452 )
453
454 # Returns `[nonMatch, adjacentMatchAfter]`, where `adjacentMatchAfter - nonMatch
455 # == 1`. `fn(n)` is supposed to return `false` for `n <= nonMatch` and `true`
456 # for `n >= adjacentMatchAfter`. Both `nonMatch` and `adjacentMatchAfter` may be
457 # `null` if they cannot be found. Otherwise they’re in the range `min <= n <=
458 # max`. `[null, null]` is returned in non-sensical cases. This function is
459 # intended to be used as a faster alternative to something like this:
460 #
461 # adjacentMatchAfter = null
462 # for n in [min..max]
463 # if fn(n)
464 # adjacentMatchAfter = n
465 # break
466 bisect = (min, max, fn) ->
467 return [null, null] unless max - min >= 0 and min % 1 == 0 and max % 1 == 0
468
469 while max - min > 1
470 mid = min + (max - min) // 2
471 match = fn(mid)
472 if match
473 max = mid
474 else
475 min = mid
476
477 matchMin = fn(min)
478 matchMax = fn(max)
479
480 return switch
481 when matchMin and matchMax
482 [null, min]
483 when not matchMin and not matchMax
484 [max, null]
485 when not matchMin and matchMax
486 [min, max]
487 else
488 [null, null]
489
490 has = (obj, prop) -> Object::hasOwnProperty.call(obj, prop)
491
492 # Check if `search` exists in `string` (case insensitively). Returns `false` if
493 # `string` doesn’t exist or isn’t a string, such as `<SVG element>.className`.
494 includes = (string, search) ->
495 return false unless typeof string == 'string'
496 return string.toLowerCase().includes(search)
497
498 nextTick = (window, fn) -> window.setTimeout((-> fn()) , 0)
499
500 regexEscape = (s) -> s.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
501
502 removeDuplicates = (array) -> Array.from(new Set(array))
503
504 # Remove duplicate characters from string (case insensitive).
505 removeDuplicateCharacters = (str) ->
506 return removeDuplicates( str.toLowerCase().split('') ).join('')
507
508 # Calls `fn` repeatedly, with at least `interval` ms between each call.
509 interval = (window, interval, fn) ->
510 stopped = false
511 currentIntervalId = null
512 next = ->
513 return if stopped
514 currentIntervalId = window.setTimeout((-> fn(next)), interval)
515 clearInterval = ->
516 stopped = true
517 window.clearTimeout(currentIntervalId)
518 next()
519 return clearInterval
520
521
522
523 # Misc helpers
524
525 expandPath = (path) ->
526 if path.startsWith('~/') or path.startsWith('~\\')
527 return OS.Constants.Path.homeDir + path[1..]
528 else
529 return path
530
531 formatError = (error) ->
532 stack = String(error.stack?.formattedStack ? error.stack ? '')
533 .split('\n')
534 .filter((line) -> line.includes('.xpi!'))
535 .map((line) -> ' ' + line.replace(/(?:\/<)*@.+\.xpi!/g, '@'))
536 .join('\n')
537 return "#{error}\n#{stack}"
538
539 getCurrentLocation = ->
540 return unless window = getCurrentWindow()
541 return new window.URL(window.gBrowser.selectedBrowser.currentURI.spec)
542
543 # This function might return `null` on startup.
544 getCurrentWindow = -> nsIWindowMediator.getMostRecentWindow('navigator:browser')
545
546 hasEventListeners = (element, type) ->
547 for listener in nsIEventListenerService.getListenerInfoFor(element)
548 if listener.listenerObject and listener.type == type
549 return true
550 return false
551
552 loadCss = (uriString) ->
553 uri = Services.io.newURI(uriString, null, null)
554 method = nsIStyleSheetService.AUTHOR_SHEET
555 unless nsIStyleSheetService.sheetRegistered(uri, method)
556 nsIStyleSheetService.loadAndRegisterSheet(uri, method)
557 module.onShutdown(->
558 nsIStyleSheetService.unregisterSheet(uri, method)
559 )
560
561 observe = (topic, observer) ->
562 observer = {observe: observer} if typeof observer == 'function'
563 Services.obs.addObserver(observer, topic, false)
564 module.onShutdown(->
565 Services.obs.removeObserver(observer, topic, false)
566 )
567
568 # Try to open a button’s dropdown menu, if any.
569 openDropdown = (element) ->
570 if element.ownerDocument instanceof XULDocument and
571 element.getAttribute?('type') == 'menu' and
572 element.open == false # Only change `.open` if it is already a boolean.
573 element.open = true
574
575 openPopup = (popup) ->
576 window = popup.ownerGlobal
577 # Show the popup so it gets a height and width.
578 popup.openPopupAtScreen(0, 0)
579 # Center the popup inside the window.
580 popup.moveTo(
581 window.screenX + window.outerWidth / 2 - popup.clientWidth / 2,
582 window.screenY + window.outerHeight / 2 - popup.clientHeight / 2
583 )
584
585 writeToClipboard = (text) -> nsIClipboardHelper.copyString(text)
586
587
588
589 module.exports = {
590 isActivatable
591 isAdjustable
592 isContentEditable
593 isDevtoolsElement
594 isDevtoolsWindow
595 isFocusable
596 isIframeEditor
597 isIgnoreModeFocusType
598 isProperLink
599 isTextInputElement
600 isTypingElement
601
602 getActiveElement
603 blurActiveElement
604 blurActiveBrowserElement
605 focusElement
606 getFocusType
607
608 listen
609 listenOnce
610 onRemoved
611 suppressEvent
612 simulateMouseEvents
613
614 area
615 checkElementOrAncestor
616 clearSelectionDeep
617 containsDeep
618 createBox
619 getRootElement
620 injectTemporaryPopup
621 insertText
622 isDetached
623 isNonEmptyTextNode
624 isPositionFixed
625 querySelectorAllDeep
626 setAttributes
627 setHover
628
629 Counter
630 EventEmitter
631 bisect
632 has
633 includes
634 nextTick
635 regexEscape
636 removeDuplicates
637 removeDuplicateCharacters
638 interval
639
640 expandPath
641 formatError
642 getCurrentLocation
643 getCurrentWindow
644 hasEventListeners
645 loadCss
646 observe
647 openDropdown
648 openPopup
649 writeToClipboard
650 }
Imprint / Impressum