]> git.gir.st - VimFx.git/blob - extension/lib/utils.coffee
Allow `<space>` to toggle play/pause in more video players
[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 containsDeep = (parent, element) ->
320 parentWindow = parent.ownerGlobal
321 elementWindow = element.ownerGlobal
322
323 # Owner windows might be missing when opening the devtools.
324 while elementWindow and parentWindow and
325 elementWindow != parentWindow and elementWindow.top != elementWindow
326 element = elementWindow.frameElement
327 elementWindow = element.ownerGlobal
328
329 return parent.contains(element)
330
331 createBox = (document, className = '', parent = null, text = null) ->
332 box = document.createElement('box')
333 box.className = "#{className} vimfx-box"
334 box.textContent = text if text?
335 parent.appendChild(box) if parent?
336 return box
337
338 # In quirks mode (when the page lacks a doctype), such as on Hackernews,
339 # `<body>` is considered the root element rather than `<html>`.
340 getRootElement = (document) ->
341 if document.compatMode == 'BackCompat' and document.body?
342 return document.body
343 else
344 return document.documentElement
345
346
347 # Returns the minimum of `element.clientHeight` and the height of the viewport,
348 # taking fixed headers and footers into account. Adapted from Firefox’s source
349 # code for `<space>` scrolling (which is where the arbitrary constants below
350 # come from).
351 #
352 # coffeelint: disable=max_line_length
353 # <https://hg.mozilla.org/mozilla-central/file/4d75bd6fd234/layout/generic/nsGfxScrollFrame.cpp#l3829>
354 # coffeelint: enable=max_line_length
355 getViewportCappedClientHeight = (element) ->
356 window = element.ownerGlobal
357 viewport = getWindowViewport(window)
358 headerBottom = viewport.top
359 footerTop = viewport.bottom
360 maxHeight = viewport.height / 3
361 minWidth = Math.min(viewport.width / 2, 800)
362
363 # Restricting the candidates for headers and footers to the most likely set of
364 # elements results in a noticeable performance boost.
365 candidates = window.document.querySelectorAll(
366 'div, ul, nav, header, footer, section'
367 )
368
369 for candidate in candidates
370 rect = candidate.getBoundingClientRect()
371 continue unless rect.height <= maxHeight and rect.width >= minWidth
372 # Checking for `position: fixed;` is the absolutely most expensive
373 # operation, so that is done last.
374 switch
375 when rect.top <= headerBottom and rect.bottom > headerBottom and
376 isPositionFixed(candidate)
377 headerBottom = rect.bottom
378 when rect.bottom >= footerTop and rect.top < footerTop and
379 isPositionFixed(candidate)
380 footerTop = rect.top
381
382 return Math.min(element.clientHeight, footerTop - headerBottom)
383
384 getWindowViewport = (window) ->
385 {
386 clientWidth, clientHeight # Viewport size excluding scrollbars, usually.
387 scrollWidth, scrollHeight
388 } = getRootElement(window.document)
389 {innerWidth, innerHeight} = window # Viewport size including scrollbars.
390 # We don’t want markers to cover the scrollbars, so we should use
391 # `clientWidth` and `clientHeight`. However, when there are no scrollbars
392 # those might be too small. Then we use `innerWidth` and `innerHeight`.
393 width = if scrollWidth > innerWidth then clientWidth else innerWidth
394 height = if scrollHeight > innerHeight then clientHeight else innerHeight
395 return {
396 left: 0
397 top: 0
398 right: width
399 bottom: height
400 width
401 height
402 }
403
404 injectTemporaryPopup = (document, contents) ->
405 popup = document.createElement('menupopup')
406 popup.appendChild(contents)
407 document.getElementById('mainPopupSet').appendChild(popup)
408 listenOnce(popup, 'popuphidden', popup.remove.bind(popup))
409 return popup
410
411 insertText = (input, value) ->
412 {selectionStart, selectionEnd} = input
413 input.value =
414 input.value[0...selectionStart] + value + input.value[selectionEnd..]
415 input.selectionStart = input.selectionEnd = selectionStart + value.length
416
417 isDetached = (element) ->
418 return not element.ownerDocument?.documentElement?.contains?(element)
419
420 isPositionFixed = (element) ->
421 computedStyle = element.ownerGlobal.getComputedStyle(element)
422 return computedStyle?.getPropertyValue('position') == 'fixed'
423
424 querySelectorAllDeep = (window, selector) ->
425 elements = Array.from(window.document.querySelectorAll(selector))
426 for frame in window.frames
427 elements.push(querySelectorAllDeep(frame, selector)...)
428 return elements
429
430 scroll = (element, args) ->
431 {method, type, directions, amounts, properties, adjustment, smooth} = args
432 options = {}
433 for direction, index in directions
434 amount = amounts[index]
435 options[direction] = -Math.sign(amount) * adjustment + switch type
436 when 'lines'
437 amount
438 when 'pages'
439 amount *
440 if properties[index] == 'clientHeight'
441 getViewportCappedClientHeight(element)
442 else
443 element[properties[index]]
444 when 'other'
445 Math.min(amount, element[properties[index]])
446 options.behavior = 'smooth' if smooth
447 element[method](options)
448
449 setAttributes = (element, attributes) ->
450 for attribute, value of attributes
451 element.setAttribute(attribute, value)
452 return
453
454 setHover = (element, hover) ->
455 method = if hover then 'addPseudoClassLock' else 'removePseudoClassLock'
456 while element.parentElement
457 nsIDomUtils[method](element, ':hover')
458 element = element.parentElement
459 return
460
461
462
463 # Language helpers
464
465 class Counter
466 constructor: ({start: @value = 0, @step = 1}) ->
467 tick: -> @value += @step
468
469 class EventEmitter
470 constructor: ->
471 @listeners = {}
472
473 on: (event, listener) ->
474 (@listeners[event] ?= new Set()).add(listener)
475
476 off: (event, listener) ->
477 @listeners[event]?.delete(listener)
478
479 emit: (event, data) ->
480 @listeners[event]?.forEach((listener) ->
481 listener(data)
482 )
483
484 has = (obj, prop) -> Object::hasOwnProperty.call(obj, prop)
485
486 # Check if `search` exists in `string` (case insensitively). Returns `false` if
487 # `string` doesn’t exist or isn’t a string, such as `<SVG element>.className`.
488 includes = (string, search) ->
489 return false unless typeof string == 'string'
490 return string.toLowerCase().includes(search)
491
492
493 nextTick = (window, fn) -> window.setTimeout((-> fn()) , 0)
494
495 regexEscape = (s) -> s.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
496
497 removeDuplicates = (array) -> Array.from(new Set(array))
498
499 # Remove duplicate characters from string (case insensitive).
500 removeDuplicateCharacters = (str) ->
501 return removeDuplicates( str.toLowerCase().split('') ).join('')
502
503 # Calls `fn` repeatedly, with at least `interval` ms between each call.
504 interval = (window, interval, fn) ->
505 stopped = false
506 currentIntervalId = null
507 next = ->
508 return if stopped
509 currentIntervalId = window.setTimeout((-> fn(next)), interval)
510 clearInterval = ->
511 stopped = true
512 window.clearTimeout(currentIntervalId)
513 next()
514 return clearInterval
515
516
517
518 # Misc helpers
519
520 expandPath = (path) ->
521 if path.startsWith('~/') or path.startsWith('~\\')
522 return OS.Constants.Path.homeDir + path[1..]
523 else
524 return path
525
526 formatError = (error) ->
527 stack = String(error.stack?.formattedStack ? error.stack ? '')
528 .split('\n')
529 .filter((line) -> line.includes('.xpi!'))
530 .map((line) -> ' ' + line.replace(/(?:\/<)*@.+\.xpi!/g, '@'))
531 .join('\n')
532 return "#{error}\n#{stack}"
533
534 getCurrentLocation = ->
535 window = getCurrentWindow()
536 return new window.URL(window.gBrowser.selectedBrowser.currentURI.spec)
537
538 getCurrentWindow = -> nsIWindowMediator.getMostRecentWindow('navigator:browser')
539
540 hasEventListeners = (element, type) ->
541 for listener in nsIEventListenerService.getListenerInfoFor(element)
542 if listener.listenerObject and listener.type == type
543 return true
544 return false
545
546 loadCss = (uriString) ->
547 uri = Services.io.newURI(uriString, null, null)
548 method = nsIStyleSheetService.AUTHOR_SHEET
549 unless nsIStyleSheetService.sheetRegistered(uri, method)
550 nsIStyleSheetService.loadAndRegisterSheet(uri, method)
551 module.onShutdown(->
552 nsIStyleSheetService.unregisterSheet(uri, method)
553 )
554
555 observe = (topic, observer) ->
556 observer = {observe: observer} if typeof observer == 'function'
557 Services.obs.addObserver(observer, topic, false)
558 module.onShutdown(->
559 Services.obs.removeObserver(observer, topic, false)
560 )
561
562 openPopup = (popup) ->
563 window = popup.ownerGlobal
564 # Show the popup so it gets a height and width.
565 popup.openPopupAtScreen(0, 0)
566 # Center the popup inside the window.
567 popup.moveTo(
568 window.screenX + window.outerWidth / 2 - popup.clientWidth / 2,
569 window.screenY + window.outerHeight / 2 - popup.clientHeight / 2
570 )
571
572 openTab = (window, url, options) ->
573 {gBrowser} = window
574 window.TreeStyleTabService?.readyToOpenChildTab(gBrowser.selectedTab)
575 gBrowser.loadOneTab(url, options)
576
577 writeToClipboard = (text) -> nsIClipboardHelper.copyString(text)
578
579
580
581 module.exports = {
582 isActivatable
583 isAdjustable
584 isContentEditable
585 isFocusable
586 isIframeEditor
587 isIgnoreModeFocusType
588 isProperLink
589 isTextInputElement
590 isTypingElement
591
592 getActiveElement
593 blurActiveElement
594 blurActiveBrowserElement
595 focusElement
596 getFocusType
597
598 listen
599 listenOnce
600 onRemoved
601 suppressEvent
602 simulateMouseEvents
603
604 area
605 containsDeep
606 createBox
607 getRootElement
608 getViewportCappedClientHeight
609 getWindowViewport
610 injectTemporaryPopup
611 insertText
612 isDetached
613 isPositionFixed
614 querySelectorAllDeep
615 scroll
616 setAttributes
617 setHover
618
619 Counter
620 EventEmitter
621 has
622 includes
623 nextTick
624 regexEscape
625 removeDuplicates
626 removeDuplicateCharacters
627 interval
628
629 expandPath
630 formatError
631 getCurrentLocation
632 getCurrentWindow
633 hasEventListeners
634 loadCss
635 observe
636 openPopup
637 openTab
638 writeToClipboard
639 }
Imprint / Impressum