]> git.gir.st - VimFx.git/blob - extension/lib/utils.coffee
Add UI to export, import and reset all prefs
[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 activeElement.closest('tabmodalprompt')?.abortPrompt()
183 vim._send('browserRefocus')
184 nextTick(window, ->
185 activeElement.blur()
186 window.gBrowser.selectedBrowser.focus()
187 )
188
189 blurActiveElement = (window) ->
190 # Blurring a frame element also blurs any active elements inside it. Recursing
191 # into the frames and blurring the “real” active element directly would give
192 # focus to the `<body>` of its containing frame, while blurring the top-most
193 # frame gives focus to the top-most `<body>`. This allows to blur fancy text
194 # editors which use an `<iframe>` as their text area.
195 window.document.activeElement?.blur()
196
197 # Focus an element and tell Firefox that the focus happened because of a user
198 # action (not just because some random programmatic focus). `.FLAG_BYKEY` might
199 # look more appropriate, but it unconditionally selects all text, which
200 # `.FLAG_BYMOUSE` does not.
201 focusElement = (element, options = {}) ->
202 nsIFocusManager.setFocus(element, options.flag ? 'FLAG_BYMOUSE')
203 element.select?() if options.select
204
205 # NOTE: In frame scripts, `document.activeElement` may be `null` when the page
206 # is loading. Therefore always check if anything was returned, such as:
207 #
208 # return unless activeElement = utils.getActiveElement(window)
209 getActiveElement = (window) ->
210 {activeElement} = window.document
211 return null unless activeElement
212 # If the active element is a frame, recurse into it. The easiest way to detect
213 # a frame that works both in browser UI and in web page content is to check
214 # for the presence of `.contentWindow`. However, in non-multi-process,
215 # `<browser>` (sometimes `<xul:browser>`) elements have a `.contentWindow`
216 # pointing to the web page content `window`, which we don’t want to recurse
217 # into. The problem is that there are _some_ `<browser>`s which we _want_ to
218 # recurse into, such as the sidebar (for instance the history sidebar), and
219 # dialogs in `about:preferences`. Checking the `contextmenu` attribute seems
220 # to be a reliable test, catching both the main tab `<browser>`s and bookmarks
221 # opened in the sidebar.
222 if (activeElement.localName == 'browser' and
223 activeElement.getAttribute?('contextmenu') == 'contentAreaContextMenu') or
224 not activeElement.contentWindow
225 return activeElement
226 else
227 return getActiveElement(activeElement.contentWindow)
228
229 getFocusType = (element) -> switch
230 when isIgnoreModeFocusType(element)
231 'ignore'
232 when isTypingElement(element)
233 if element.closest?('findbar') then 'findbar' else 'editable'
234 when isActivatable(element)
235 'activatable'
236 when isAdjustable(element)
237 'adjustable'
238 else
239 'none'
240
241
242
243 # Event helpers
244
245 listen = (element, eventName, listener, useCapture = true) ->
246 element.addEventListener(eventName, listener, useCapture)
247 module.onShutdown(->
248 element.removeEventListener(eventName, listener, useCapture)
249 )
250
251 listenOnce = (element, eventName, listener, useCapture = true) ->
252 fn = (event) ->
253 listener(event)
254 element.removeEventListener(eventName, fn, useCapture)
255 listen(element, eventName, fn, useCapture)
256
257 onRemoved = (element, fn) ->
258 window = element.ownerGlobal
259
260 disconnected = false
261 disconnect = ->
262 return if disconnected
263 disconnected = true
264 mutationObserver.disconnect() unless Cu.isDeadWrapper(mutationObserver)
265
266 mutationObserver = new window.MutationObserver((changes) ->
267 for change in changes then for removedElement in change.removedNodes
268 if removedElement.contains?(element)
269 disconnect()
270 fn()
271 return
272 )
273 mutationObserver.observe(window.document.documentElement, {
274 childList: true
275 subtree: true
276 })
277 module.onShutdown(disconnect)
278
279 return disconnect
280
281 simulateMouseEvents = (element, sequence) ->
282 window = element.ownerGlobal
283 rect = element.getBoundingClientRect()
284
285 eventSequence = switch sequence
286 when 'click'
287 EVENTS_CLICK
288 when 'click-xul'
289 EVENTS_CLICK_XUL
290 when 'hover-start'
291 EVENTS_HOVER_START
292 when 'hover-end'
293 EVENTS_HOVER_END
294 else
295 sequence
296
297 for type in eventSequence
298 buttonNum = if type in EVENTS_CLICK then 1 else 0
299 mouseEvent = new window.MouseEvent(type, {
300 # Let the event bubble in order to trigger delegated event listeners.
301 bubbles: type not in ['mouseenter', 'mouseleave']
302 # Make the event cancelable so that `<a href="#">` can be used as a
303 # JavaScript-powered button without scrolling to the top of the page.
304 cancelable: type not in ['mouseenter', 'mouseleave']
305 # These properties are just here for mimicing a real click as much as
306 # possible.
307 buttons: buttonNum
308 detail: buttonNum
309 view: window
310 # `page{X,Y}` are set automatically to the correct values when setting
311 # `client{X,Y}`. `{offset,layer,movement}{X,Y}` are not worth the trouble
312 # to set.
313 clientX: rect.left
314 clientY: rect.top
315 # To exactly calculate `screen{X,Y}` one has to to check where the web
316 # page content area is inside the browser chrome and go through all parent
317 # frames as well. This is good enough. YAGNI for now.
318 screenX: window.screenX + rect.left
319 screenY: window.screenY + rect.top
320 })
321 if type == 'mousemove'
322 # If the below technique is used for this event, the “URL popup” (shown
323 # when hovering or focusing links) does not appear.
324 element.dispatchEvent(mouseEvent)
325 else
326 # The last `true` below marks the event as trusted, which some APIs
327 # require, such as `requestFullscreen()`.
328 # (`element.dispatchEvent(mouseEvent)` is not able to do this.)
329 window
330 .QueryInterface(Ci.nsIInterfaceRequestor)
331 .getInterface(Ci.nsIDOMWindowUtils)
332 .dispatchDOMEventViaPresShell(element, mouseEvent, true)
333
334 return
335
336 suppressEvent = (event) ->
337 event.preventDefault()
338 event.stopPropagation()
339
340
341
342 # DOM helpers
343
344 area = (element) ->
345 return element.clientWidth * element.clientHeight
346
347 checkElementOrAncestor = (element, fn) ->
348 window = element.ownerGlobal
349 while element.parentElement
350 return true if fn(element)
351 element = element.parentElement
352 return false
353
354 clearSelectionDeep = (window) ->
355 # The selection might be `null` in hidden frames.
356 selection = window.getSelection()
357 selection?.removeAllRanges()
358 for frame in window.frames
359 clearSelectionDeep(frame)
360 # Allow parents to re-gain control of text selection.
361 frame.frameElement.blur()
362 return
363
364 containsDeep = (parent, element) ->
365 parentWindow = parent.ownerGlobal
366 elementWindow = element.ownerGlobal
367
368 # Owner windows might be missing when opening the devtools.
369 while elementWindow and parentWindow and
370 elementWindow != parentWindow and elementWindow.top != elementWindow
371 element = elementWindow.frameElement
372 elementWindow = element.ownerGlobal
373
374 return parent.contains(element)
375
376 createBox = (document, className = '', parent = null, text = null) ->
377 box = document.createElement('box')
378 box.className = "#{className} vimfx-box"
379 box.textContent = text if text?
380 parent.appendChild(box) if parent?
381 return box
382
383 # In quirks mode (when the page lacks a doctype), such as on Hackernews,
384 # `<body>` is considered the root element rather than `<html>`.
385 getRootElement = (document) ->
386 if document.compatMode == 'BackCompat' and document.body?
387 return document.body
388 else
389 return document.documentElement
390
391 injectTemporaryPopup = (document, contents) ->
392 popup = document.createElement('menupopup')
393 popup.appendChild(contents)
394 document.getElementById('mainPopupSet').appendChild(popup)
395 listenOnce(popup, 'popuphidden', popup.remove.bind(popup))
396 return popup
397
398 insertText = (input, value) ->
399 {selectionStart, selectionEnd} = input
400 input.value =
401 input.value[0...selectionStart] + value + input.value[selectionEnd..]
402 input.selectionStart = input.selectionEnd = selectionStart + value.length
403
404 isDetached = (element) ->
405 return not element.ownerDocument?.documentElement?.contains?(element)
406
407 isNonEmptyTextNode = (node) ->
408 return node.nodeType == 3 and node.data.trim() != ''
409
410 isPositionFixed = (element) ->
411 computedStyle = element.ownerGlobal.getComputedStyle(element)
412 return computedStyle?.getPropertyValue('position') == 'fixed'
413
414 querySelectorAllDeep = (window, selector) ->
415 elements = Array.from(window.document.querySelectorAll(selector))
416 for frame in window.frames
417 elements.push(querySelectorAllDeep(frame, selector)...)
418 return elements
419
420 setAttributes = (element, attributes) ->
421 for attribute, value of attributes
422 element.setAttribute(attribute, value)
423 return
424
425 setHover = (element, hover) ->
426 method = if hover then 'addPseudoClassLock' else 'removePseudoClassLock'
427 while element.parentElement
428 nsIDomUtils[method](element, ':hover')
429 element = element.parentElement
430 return
431
432
433
434 # Language helpers
435
436 class Counter
437 constructor: ({start: @value = 0, @step = 1}) ->
438 tick: -> @value += @step
439
440 class EventEmitter
441 constructor: ->
442 @listeners = {}
443
444 on: (event, listener) ->
445 (@listeners[event] ?= new Set()).add(listener)
446
447 off: (event, listener) ->
448 @listeners[event]?.delete(listener)
449
450 emit: (event, data) ->
451 @listeners[event]?.forEach((listener) ->
452 listener(data)
453 )
454
455 # Returns `[nonMatch, adjacentMatchAfter]`, where `adjacentMatchAfter - nonMatch
456 # == 1`. `fn(n)` is supposed to return `false` for `n <= nonMatch` and `true`
457 # for `n >= adjacentMatchAfter`. Both `nonMatch` and `adjacentMatchAfter` may be
458 # `null` if they cannot be found. Otherwise they’re in the range `min <= n <=
459 # max`. `[null, null]` is returned in non-sensical cases. This function is
460 # intended to be used as a faster alternative to something like this:
461 #
462 # adjacentMatchAfter = null
463 # for n in [min..max]
464 # if fn(n)
465 # adjacentMatchAfter = n
466 # break
467 bisect = (min, max, fn) ->
468 return [null, null] unless max - min >= 0 and min % 1 == 0 and max % 1 == 0
469
470 while max - min > 1
471 mid = min + (max - min) // 2
472 match = fn(mid)
473 if match
474 max = mid
475 else
476 min = mid
477
478 matchMin = fn(min)
479 matchMax = fn(max)
480
481 return switch
482 when matchMin and matchMax
483 [null, min]
484 when not matchMin and not matchMax
485 [max, null]
486 when not matchMin and matchMax
487 [min, max]
488 else
489 [null, null]
490
491 has = (obj, prop) -> Object::hasOwnProperty.call(obj, prop)
492
493 # Check if `search` exists in `string` (case insensitively). Returns `false` if
494 # `string` doesn’t exist or isn’t a string, such as `<SVG element>.className`.
495 includes = (string, search) ->
496 return false unless typeof string == 'string'
497 return string.toLowerCase().includes(search)
498
499 # Calls `fn` repeatedly, with at least `interval` ms between each call.
500 interval = (window, interval, fn) ->
501 stopped = false
502 currentIntervalId = null
503 next = ->
504 return if stopped
505 currentIntervalId = window.setTimeout((-> fn(next)), interval)
506 clearInterval = ->
507 stopped = true
508 window.clearTimeout(currentIntervalId)
509 next()
510 return clearInterval
511
512 nextTick = (window, fn) -> window.setTimeout((-> fn()) , 0)
513
514 regexEscape = (s) -> s.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
515
516 # Remove duplicate characters from string (case insensitive).
517 removeDuplicateCharacters = (str) ->
518 return removeDuplicates( str.toLowerCase().split('') ).join('')
519
520 removeDuplicates = (array) -> Array.from(new Set(array))
521
522
523
524 # Misc helpers
525
526 expandPath = (path) ->
527 if path.startsWith('~/') or path.startsWith('~\\')
528 return OS.Constants.Path.homeDir + path[1..]
529 else
530 return path
531
532 formatError = (error) ->
533 stack = String(error.stack?.formattedStack ? error.stack ? '')
534 .split('\n')
535 .filter((line) -> line.includes('.xpi!'))
536 .map((line) -> ' ' + line.replace(/(?:\/<)*@.+\.xpi!/g, '@'))
537 .join('\n')
538 return "#{error}\n#{stack}"
539
540 getCurrentLocation = ->
541 return unless window = getCurrentWindow()
542 return new window.URL(window.gBrowser.selectedBrowser.currentURI.spec)
543
544 # This function might return `null` on startup.
545 getCurrentWindow = -> nsIWindowMediator.getMostRecentWindow('navigator:browser')
546
547 hasEventListeners = (element, type) ->
548 for listener in nsIEventListenerService.getListenerInfoFor(element)
549 if listener.listenerObject and listener.type == type
550 return true
551 return false
552
553 loadCss = (uriString) ->
554 uri = Services.io.newURI(uriString, null, null)
555 method = nsIStyleSheetService.AUTHOR_SHEET
556 unless nsIStyleSheetService.sheetRegistered(uri, method)
557 nsIStyleSheetService.loadAndRegisterSheet(uri, method)
558 module.onShutdown(->
559 nsIStyleSheetService.unregisterSheet(uri, method)
560 )
561
562 observe = (topic, observer) ->
563 observer = {observe: observer} if typeof observer == 'function'
564 Services.obs.addObserver(observer, topic, false)
565 module.onShutdown(->
566 Services.obs.removeObserver(observer, topic, false)
567 )
568
569 # Try to open a button’s dropdown menu, if any.
570 openDropdown = (element) ->
571 if element.ownerDocument instanceof XULDocument and
572 element.getAttribute?('type') == 'menu' and
573 element.open == false # Only change `.open` if it is already a boolean.
574 element.open = true
575
576 openPopup = (popup) ->
577 window = popup.ownerGlobal
578 # Show the popup so it gets a height and width.
579 popup.openPopupAtScreen(0, 0)
580 # Center the popup inside the window.
581 popup.moveTo(
582 window.screenX + window.outerWidth / 2 - popup.clientWidth / 2,
583 window.screenY + window.outerHeight / 2 - popup.clientHeight / 2
584 )
585
586 writeToClipboard = (text) -> nsIClipboardHelper.copyString(text)
587
588
589
590 module.exports = {
591 isActivatable
592 isAdjustable
593 isContentEditable
594 isDevtoolsElement
595 isDevtoolsWindow
596 isFocusable
597 isIframeEditor
598 isIgnoreModeFocusType
599 isProperLink
600 isTextInputElement
601 isTypingElement
602
603 blurActiveBrowserElement
604 blurActiveElement
605 focusElement
606 getActiveElement
607 getFocusType
608
609 listen
610 listenOnce
611 onRemoved
612 simulateMouseEvents
613 suppressEvent
614
615 area
616 checkElementOrAncestor
617 clearSelectionDeep
618 containsDeep
619 createBox
620 getRootElement
621 injectTemporaryPopup
622 insertText
623 isDetached
624 isNonEmptyTextNode
625 isPositionFixed
626 querySelectorAllDeep
627 setAttributes
628 setHover
629
630 Counter
631 EventEmitter
632 bisect
633 has
634 includes
635 interval
636 nextTick
637 regexEscape
638 removeDuplicateCharacters
639 removeDuplicates
640
641 expandPath
642 formatError
643 getCurrentLocation
644 getCurrentWindow
645 hasEventListeners
646 loadCss
647 observe
648 openDropdown
649 openPopup
650 writeToClipboard
651 }
Imprint / Impressum