]> git.gir.st - VimFx.git/blob - extension/lib/utils.coffee
Fix devtools detection in newer Firefox versions
[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
482 nextTick = (window, fn) -> window.setTimeout((-> fn()) , 0)
483
484 regexEscape = (s) -> s.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
485
486 removeDuplicates = (array) -> Array.from(new Set(array))
487
488 # Remove duplicate characters from string (case insensitive).
489 removeDuplicateCharacters = (str) ->
490 return removeDuplicates( str.toLowerCase().split('') ).join('')
491
492 # Calls `fn` repeatedly, with at least `interval` ms between each call.
493 interval = (window, interval, fn) ->
494 stopped = false
495 currentIntervalId = null
496 next = ->
497 return if stopped
498 currentIntervalId = window.setTimeout((-> fn(next)), interval)
499 clearInterval = ->
500 stopped = true
501 window.clearTimeout(currentIntervalId)
502 next()
503 return clearInterval
504
505
506
507 # Misc helpers
508
509 expandPath = (path) ->
510 if path.startsWith('~/') or path.startsWith('~\\')
511 return OS.Constants.Path.homeDir + path[1..]
512 else
513 return path
514
515 formatError = (error) ->
516 stack = String(error.stack?.formattedStack ? error.stack ? '')
517 .split('\n')
518 .filter((line) -> line.includes('.xpi!'))
519 .map((line) -> ' ' + line.replace(/(?:\/<)*@.+\.xpi!/g, '@'))
520 .join('\n')
521 return "#{error}\n#{stack}"
522
523 getCurrentLocation = ->
524 window = getCurrentWindow()
525 return new window.URL(window.gBrowser.selectedBrowser.currentURI.spec)
526
527 getCurrentWindow = -> nsIWindowMediator.getMostRecentWindow('navigator:browser')
528
529 hasEventListeners = (element, type) ->
530 for listener in nsIEventListenerService.getListenerInfoFor(element)
531 if listener.listenerObject and listener.type == type
532 return true
533 return false
534
535 loadCss = (uriString) ->
536 uri = Services.io.newURI(uriString, null, null)
537 method = nsIStyleSheetService.AUTHOR_SHEET
538 unless nsIStyleSheetService.sheetRegistered(uri, method)
539 nsIStyleSheetService.loadAndRegisterSheet(uri, method)
540 module.onShutdown(->
541 nsIStyleSheetService.unregisterSheet(uri, method)
542 )
543
544 observe = (topic, observer) ->
545 observer = {observe: observer} if typeof observer == 'function'
546 Services.obs.addObserver(observer, topic, false)
547 module.onShutdown(->
548 Services.obs.removeObserver(observer, topic, false)
549 )
550
551 openPopup = (popup) ->
552 window = popup.ownerGlobal
553 # Show the popup so it gets a height and width.
554 popup.openPopupAtScreen(0, 0)
555 # Center the popup inside the window.
556 popup.moveTo(
557 window.screenX + window.outerWidth / 2 - popup.clientWidth / 2,
558 window.screenY + window.outerHeight / 2 - popup.clientHeight / 2
559 )
560
561 writeToClipboard = (text) -> nsIClipboardHelper.copyString(text)
562
563
564
565 module.exports = {
566 isActivatable
567 isAdjustable
568 isContentEditable
569 isDevtoolsElement
570 isDevtoolsWindow
571 isFocusable
572 isIframeEditor
573 isIgnoreModeFocusType
574 isProperLink
575 isTextInputElement
576 isTypingElement
577
578 getActiveElement
579 blurActiveElement
580 blurActiveBrowserElement
581 focusElement
582 getFocusType
583
584 listen
585 listenOnce
586 onRemoved
587 suppressEvent
588 simulateMouseEvents
589
590 area
591 checkElementOrAncestor
592 clearSelectionDeep
593 containsDeep
594 createBox
595 getRootElement
596 injectTemporaryPopup
597 insertText
598 isDetached
599 isNonEmptyTextNode
600 isPositionFixed
601 querySelectorAllDeep
602 setAttributes
603 setHover
604
605 Counter
606 EventEmitter
607 bisect
608 has
609 includes
610 nextTick
611 regexEscape
612 removeDuplicates
613 removeDuplicateCharacters
614 interval
615
616 expandPath
617 formatError
618 getCurrentLocation
619 getCurrentWindow
620 hasEventListeners
621 loadCss
622 observe
623 openPopup
624 writeToClipboard
625 }
Imprint / Impressum