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