]> git.gir.st - VimFx.git/blob - extension/lib/utils.coffee
Fix broken `yv` command
[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 window = getCurrentWindow()
541 return new window.URL(window.gBrowser.selectedBrowser.currentURI.spec)
542
543 getCurrentWindow = -> nsIWindowMediator.getMostRecentWindow('navigator:browser')
544
545 hasEventListeners = (element, type) ->
546 for listener in nsIEventListenerService.getListenerInfoFor(element)
547 if listener.listenerObject and listener.type == type
548 return true
549 return false
550
551 loadCss = (uriString) ->
552 uri = Services.io.newURI(uriString, null, null)
553 method = nsIStyleSheetService.AUTHOR_SHEET
554 unless nsIStyleSheetService.sheetRegistered(uri, method)
555 nsIStyleSheetService.loadAndRegisterSheet(uri, method)
556 module.onShutdown(->
557 nsIStyleSheetService.unregisterSheet(uri, method)
558 )
559
560 observe = (topic, observer) ->
561 observer = {observe: observer} if typeof observer == 'function'
562 Services.obs.addObserver(observer, topic, false)
563 module.onShutdown(->
564 Services.obs.removeObserver(observer, topic, false)
565 )
566
567 # Try to open a button’s dropdown menu, if any.
568 openDropdown = (element) ->
569 if element.ownerDocument instanceof XULDocument and
570 element.getAttribute?('type') == 'menu' and
571 element.open == false # Only change `.open` if it is already a boolean.
572 element.open = true
573
574 openPopup = (popup) ->
575 window = popup.ownerGlobal
576 # Show the popup so it gets a height and width.
577 popup.openPopupAtScreen(0, 0)
578 # Center the popup inside the window.
579 popup.moveTo(
580 window.screenX + window.outerWidth / 2 - popup.clientWidth / 2,
581 window.screenY + window.outerHeight / 2 - popup.clientHeight / 2
582 )
583
584 writeToClipboard = (text) -> nsIClipboardHelper.copyString(text)
585
586
587
588 module.exports = {
589 isActivatable
590 isAdjustable
591 isContentEditable
592 isDevtoolsElement
593 isDevtoolsWindow
594 isFocusable
595 isIframeEditor
596 isIgnoreModeFocusType
597 isProperLink
598 isTextInputElement
599 isTypingElement
600
601 getActiveElement
602 blurActiveElement
603 blurActiveBrowserElement
604 focusElement
605 getFocusType
606
607 listen
608 listenOnce
609 onRemoved
610 suppressEvent
611 simulateMouseEvents
612
613 area
614 checkElementOrAncestor
615 clearSelectionDeep
616 containsDeep
617 createBox
618 getRootElement
619 injectTemporaryPopup
620 insertText
621 isDetached
622 isNonEmptyTextNode
623 isPositionFixed
624 querySelectorAllDeep
625 setAttributes
626 setHover
627
628 Counter
629 EventEmitter
630 bisect
631 has
632 includes
633 nextTick
634 regexEscape
635 removeDuplicates
636 removeDuplicateCharacters
637 interval
638
639 expandPath
640 formatError
641 getCurrentLocation
642 getCurrentWindow
643 hasEventListeners
644 loadCss
645 observe
646 openDropdown
647 openPopup
648 writeToClipboard
649 }
Imprint / Impressum