]> git.gir.st - VimFx.git/blob - extension/lib/utils.coffee
Fix alphabetic sorting of functions in utils.coffee
[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 blurActiveBrowserElement = (vim) ->
173 # - Blurring in the next tick allows to pass `<escape>` to the location bar to
174 # reset it, for example.
175 # - Focusing the current browser afterwards allows to pass `<escape>` as well
176 # as unbound keys to the page. However, focusing the browser also triggers
177 # focus events on `document` and `window` in the current page. Many pages
178 # re-focus some text input on those events, making it impossible to blur
179 # those! Therefore we tell the frame script to suppress those events.
180 {window} = vim
181 activeElement = getActiveElement(window)
182 vim._send('browserRefocus')
183 nextTick(window, ->
184 activeElement.blur()
185 window.gBrowser.selectedBrowser.focus()
186 )
187
188 blurActiveElement = (window) ->
189 # Blurring a frame element also blurs any active elements inside it. Recursing
190 # into the frames and blurring the “real” active element directly would give
191 # focus to the `<body>` of its containing frame, while blurring the top-most
192 # frame gives focus to the top-most `<body>`. This allows to blur fancy text
193 # editors which use an `<iframe>` as their text area.
194 window.document.activeElement?.blur()
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 # NOTE: In frame scripts, `document.activeElement` may be `null` when the page
205 # is loading. Therefore always check if anything was returned, such as:
206 #
207 # return unless activeElement = utils.getActiveElement(window)
208 getActiveElement = (window) ->
209 {activeElement} = window.document
210 return null unless activeElement
211 # If the active element is a frame, recurse into it. The easiest way to detect
212 # a frame that works both in browser UI and in web page content is to check
213 # for the presence of `.contentWindow`. However, in non-multi-process,
214 # `<browser>` (sometimes `<xul:browser>`) elements have a `.contentWindow`
215 # pointing to the web page content `window`, which we don’t want to recurse
216 # into. The problem is that there are _some_ `<browser>`s which we _want_ to
217 # recurse into, such as the sidebar (for instance the history sidebar), and
218 # dialogs in `about:preferences`. Checking the `contextmenu` attribute seems
219 # to be a reliable test, catching both the main tab `<browser>`s and bookmarks
220 # opened in the sidebar.
221 if (activeElement.localName == 'browser' and
222 activeElement.getAttribute?('contextmenu') == 'contentAreaContextMenu') or
223 not activeElement.contentWindow
224 return activeElement
225 else
226 return getActiveElement(activeElement.contentWindow)
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 simulateMouseEvents = (element, sequence) ->
281 window = element.ownerGlobal
282 rect = element.getBoundingClientRect()
283
284 eventSequence = switch sequence
285 when 'click'
286 EVENTS_CLICK
287 when 'click-xul'
288 EVENTS_CLICK_XUL
289 when 'hover-start'
290 EVENTS_HOVER_START
291 when 'hover-end'
292 EVENTS_HOVER_END
293 else
294 sequence
295
296 for type in eventSequence
297 buttonNum = if type in EVENTS_CLICK then 1 else 0
298 mouseEvent = new window.MouseEvent(type, {
299 # Let the event bubble in order to trigger delegated event listeners.
300 bubbles: type not in ['mouseenter', 'mouseleave']
301 # Make the event cancelable so that `<a href="#">` can be used as a
302 # JavaScript-powered button without scrolling to the top of the page.
303 cancelable: type not in ['mouseenter', 'mouseleave']
304 # These properties are just here for mimicing a real click as much as
305 # possible.
306 buttons: buttonNum
307 detail: buttonNum
308 view: window
309 # `page{X,Y}` are set automatically to the correct values when setting
310 # `client{X,Y}`. `{offset,layer,movement}{X,Y}` are not worth the trouble
311 # to set.
312 clientX: rect.left
313 clientY: rect.top
314 # To exactly calculate `screen{X,Y}` one has to to check where the web
315 # page content area is inside the browser chrome and go through all parent
316 # frames as well. This is good enough. YAGNI for now.
317 screenX: window.screenX + rect.left
318 screenY: window.screenY + rect.top
319 })
320 if type == 'mousemove'
321 # If the below technique is used for this event, the “URL popup” (shown
322 # when hovering or focusing links) does not appear.
323 element.dispatchEvent(mouseEvent)
324 else
325 # The last `true` below marks the event as trusted, which some APIs
326 # require, such as `requestFullscreen()`.
327 # (`element.dispatchEvent(mouseEvent)` is not able to do this.)
328 window
329 .QueryInterface(Ci.nsIInterfaceRequestor)
330 .getInterface(Ci.nsIDOMWindowUtils)
331 .dispatchDOMEventViaPresShell(element, mouseEvent, true)
332
333 return
334
335 suppressEvent = (event) ->
336 event.preventDefault()
337 event.stopPropagation()
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 # Calls `fn` repeatedly, with at least `interval` ms between each call.
499 interval = (window, interval, fn) ->
500 stopped = false
501 currentIntervalId = null
502 next = ->
503 return if stopped
504 currentIntervalId = window.setTimeout((-> fn(next)), interval)
505 clearInterval = ->
506 stopped = true
507 window.clearTimeout(currentIntervalId)
508 next()
509 return clearInterval
510
511 nextTick = (window, fn) -> window.setTimeout((-> fn()) , 0)
512
513 regexEscape = (s) -> s.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
514
515 # Remove duplicate characters from string (case insensitive).
516 removeDuplicateCharacters = (str) ->
517 return removeDuplicates( str.toLowerCase().split('') ).join('')
518
519 removeDuplicates = (array) -> Array.from(new Set(array))
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 blurActiveBrowserElement
603 blurActiveElement
604 focusElement
605 getActiveElement
606 getFocusType
607
608 listen
609 listenOnce
610 onRemoved
611 simulateMouseEvents
612 suppressEvent
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 interval
635 nextTick
636 regexEscape
637 removeDuplicateCharacters
638 removeDuplicates
639
640 expandPath
641 formatError
642 getCurrentLocation
643 getCurrentWindow
644 hasEventListeners
645 loadCss
646 observe
647 openDropdown
648 openPopup
649 writeToClipboard
650 }
Imprint / Impressum