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