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