]> git.gir.st - VimFx.git/blob - extension/lib/utils.coffee
Merge branch 'master' into develop
[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 # Youtube special case.
73 element.classList?.contains('html5-video-player') or
74 element.classList?.contains('ytp-button') or
75 # Allow navigating object inspection trees in th devtools with the
76 # arrow keys, even if the arrow keys are used as VimFx shortcuts.
77 element.ownerGlobal?.DevTools
78
79 isContentEditable = (element) ->
80 return element.isContentEditable or
81 isIframeEditor(element) or
82 # Google.
83 element.getAttribute?('g_editable') == 'true' or
84 element.ownerDocument?.body?.getAttribute?('g_editable') == 'true' or
85 # Codeacademy terminals.
86 element.classList?.contains('real-terminal')
87
88 isFocusable = (element) ->
89 return element.tabIndex > -1 and
90 not (element.localName?.endsWith?('box') and
91 element.localName != 'checkbox') and
92 element.localName not in ['tabs', 'menuitem', 'menuseparator']
93
94 isIframeEditor = (element) ->
95 return false unless element.localName == 'body'
96 return \
97 # Etherpad.
98 element.id == 'innerdocbody' or
99 # XpressEditor.
100 (element.classList?.contains('xe_content') and
101 element.classList?.contains('editable')) or
102 # vBulletin.
103 element.classList?.contains('wysiwyg') or
104 # The wasavi extension.
105 element.hasAttribute?('data-wasavi-state')
106
107 isIgnoreModeFocusType = (element) ->
108 return \
109 # The wasavi extension.
110 element.hasAttribute?('data-wasavi-state') or
111 element.closest?('#wasavi_container') or
112 # CodeMirror in Vim mode.
113 (element.localName == 'textarea' and
114 element.closest?('.CodeMirror') and _hasVimEventListener(element))
115
116 # CodeMirror’s Vim mode is really sucky to detect. The only way seems to be to
117 # check if the there are any event listener functions with Vim-y words in them.
118 _hasVimEventListener = (element) ->
119 for listener in nsIEventListenerService.getListenerInfoFor(element)
120 if listener.listenerObject and
121 /\bvim\b|insertmode/i.test(String(listener.listenerObject))
122 return true
123 return false
124
125 isProperLink = (element) ->
126 # `.getAttribute` is used below instead of `.hasAttribute` to exclude `<a
127 # href="">`s used as buttons on some sites.
128 return element.getAttribute?('href') and
129 (element.localName == 'a' or
130 element.ownerDocument instanceof XULDocument) and
131 not element.href?.endsWith?('#') and
132 not element.href?.endsWith?('#?') and
133 not element.href?.startsWith?('javascript:')
134
135 isTextInputElement = (element) ->
136 return (element.localName == 'input' and element.type in [
137 'text', 'search', 'tel', 'url', 'email', 'password', 'number'
138 ]) or
139 element.localName == 'textarea' or
140 element instanceof XULTextBoxElement or
141 isContentEditable(element)
142
143 isTypingElement = (element) ->
144 return isTextInputElement(element) or
145 # `<select>` elements can also receive text input: You may type the
146 # text of an item to select it.
147 element.localName == 'select' or
148 element instanceof XULMenuListElement
149
150
151
152 # Active/focused element helpers
153
154 # NOTE: In frame scripts, `document.activeElement` may be `null` when the page
155 # is loading. Therefore always check if anything was returned, such as:
156 #
157 # return unless activeElement = utils.getActiveElement(window)
158 getActiveElement = (window) ->
159 {activeElement} = window.document
160 return null unless activeElement
161 # If the active element is a frame, recurse into it. The easiest way to detect
162 # a frame that works both in browser UI and in web page content is to check
163 # for the presence of `.contentWindow`. However, in non-multi-process,
164 # `<browser>` (sometimes `<xul:browser>`) elements have a `.contentWindow`
165 # pointing to the web page content `window`, which we don’t want to recurse
166 # into. The problem is that there are _some_ `<browser>`s which we _want_ to
167 # recurse into, such as the sidebar (for instance the history sidebar), and
168 # dialogs in `about:preferences`. Checking the `contextmenu` attribute seems
169 # to be a reliable test, catching both the main tab `<browser>`s and bookmarks
170 # opened in the sidebar.
171 if (activeElement.localName == 'browser' and
172 activeElement.getAttribute?('contextmenu') == 'contentAreaContextMenu') or
173 not activeElement.contentWindow
174 return activeElement
175 else
176 return getActiveElement(activeElement.contentWindow)
177
178 blurActiveElement = (window) ->
179 # Blurring a frame element also blurs any active elements inside it. Recursing
180 # into the frames and blurring the “real” active element directly would give
181 # focus to the `<body>` of its containing frame, while blurring the top-most
182 # frame gives focus to the top-most `<body>`. This allows to blur fancy text
183 # editors which use an `<iframe>` as their text area.
184 window.document.activeElement?.blur()
185
186 blurActiveBrowserElement = (vim) ->
187 # - Blurring in the next tick allows to pass `<escape>` to the location bar to
188 # reset it, for example.
189 # - Focusing the current browser afterwards allows to pass `<escape>` as well
190 # as unbound keys to the page. However, focusing the browser also triggers
191 # focus events on `document` and `window` in the current page. Many pages
192 # re-focus some text input on those events, making it impossible to blur
193 # those! Therefore we tell the frame script to suppress those events.
194 {window} = vim
195 activeElement = getActiveElement(window)
196 vim._send('browserRefocus')
197 nextTick(window, ->
198 activeElement.blur()
199 window.gBrowser.selectedBrowser.focus()
200 )
201
202 # Focus an element and tell Firefox that the focus happened because of a user
203 # action (not just because some random programmatic focus). `.FLAG_BYKEY` might
204 # look more appropriate, but it unconditionally selects all text, which
205 # `.FLAG_BYMOUSE` does not.
206 focusElement = (element, options = {}) ->
207 nsIFocusManager.setFocus(element, options.flag ? 'FLAG_BYMOUSE')
208 element.select?() if options.select
209
210 getFocusType = (element) -> switch
211 when isIgnoreModeFocusType(element)
212 'ignore'
213 when isTypingElement(element)
214 if element.closest?('findbar') then 'findbar' else 'editable'
215 when isActivatable(element)
216 'activatable'
217 when isAdjustable(element)
218 'adjustable'
219 else
220 'none'
221
222
223
224 # Event helpers
225
226 listen = (element, eventName, listener, useCapture = true) ->
227 element.addEventListener(eventName, listener, useCapture)
228 module.onShutdown(->
229 element.removeEventListener(eventName, listener, useCapture)
230 )
231
232 listenOnce = (element, eventName, listener, useCapture = true) ->
233 fn = (event) ->
234 listener(event)
235 element.removeEventListener(eventName, fn, useCapture)
236 listen(element, eventName, fn, useCapture)
237
238 onRemoved = (element, fn) ->
239 window = element.ownerGlobal
240
241 disconnected = false
242 disconnect = ->
243 return if disconnected
244 disconnected = true
245 mutationObserver.disconnect() unless Cu.isDeadWrapper(mutationObserver)
246
247 mutationObserver = new window.MutationObserver((changes) ->
248 for change in changes then for removedElement in change.removedNodes
249 if removedElement.contains?(element)
250 disconnect()
251 fn()
252 return
253 )
254 mutationObserver.observe(window.document.documentElement, {
255 childList: true
256 subtree: true
257 })
258 module.onShutdown(disconnect)
259
260 return disconnect
261
262 suppressEvent = (event) ->
263 event.preventDefault()
264 event.stopPropagation()
265
266 simulateMouseEvents = (element, sequence) ->
267 window = element.ownerGlobal
268 rect = element.getBoundingClientRect()
269
270 eventSequence = switch sequence
271 when 'click'
272 EVENTS_CLICK
273 when 'click-xul'
274 EVENTS_CLICK_XUL
275 when 'hover-start'
276 EVENTS_HOVER_START
277 when 'hover-end'
278 EVENTS_HOVER_END
279 else
280 sequence
281
282 for type in eventSequence
283 buttonNum = if type in EVENTS_CLICK then 1 else 0
284 mouseEvent = new window.MouseEvent(type, {
285 # Let the event bubble in order to trigger delegated event listeners.
286 bubbles: type not in ['mouseenter', 'mouseleave']
287 # Make the event cancelable so that `<a href="#">` can be used as a
288 # JavaScript-powered button without scrolling to the top of the page.
289 cancelable: type not in ['mouseenter', 'mouseleave']
290 # These properties are just here for mimicing a real click as much as
291 # possible.
292 buttons: buttonNum
293 detail: buttonNum
294 view: window
295 # `page{X,Y}` are set automatically to the correct values when setting
296 # `client{X,Y}`. `{offset,layer,movement}{X,Y}` are not worth the trouble
297 # to set.
298 clientX: rect.left
299 clientY: rect.top
300 # To exactly calculate `screen{X,Y}` one has to to check where the web
301 # page content area is inside the browser chrome and go through all parent
302 # frames as well. This is good enough. YAGNI for now.
303 screenX: window.screenX + rect.left
304 screenY: window.screenY + rect.top
305 })
306 element.dispatchEvent(mouseEvent)
307
308 return
309
310
311
312 # DOM helpers
313
314 area = (element) ->
315 return element.clientWidth * element.clientHeight
316
317 containsDeep = (parent, element) ->
318 parentWindow = parent.ownerGlobal
319 elementWindow = element.ownerGlobal
320
321 # Owner windows might be missing when opening the devtools.
322 while elementWindow and parentWindow and
323 elementWindow != parentWindow and elementWindow.top != elementWindow
324 element = elementWindow.frameElement
325 elementWindow = element.ownerGlobal
326
327 return parent.contains(element)
328
329 createBox = (document, className = '', parent = null, text = null) ->
330 box = document.createElement('box')
331 box.className = "#{className} vimfx-box"
332 box.textContent = text if text?
333 parent.appendChild(box) if parent?
334 return box
335
336 # Returns the minimum of `element.clientHeight` and the height of the viewport,
337 # taking fixed headers and footers into account. Adapted from Firefox’s source
338 # code for `<space>` scrolling (which is where the arbitrary constants below
339 # come from).
340 #
341 # coffeelint: disable=max_line_length
342 # <https://hg.mozilla.org/mozilla-central/file/4d75bd6fd234/layout/generic/nsGfxScrollFrame.cpp#l3829>
343 # coffeelint: enable=max_line_length
344 getViewportCappedClientHeight = (element) ->
345 window = element.ownerGlobal
346 viewport = getWindowViewport(window)
347 headerBottom = viewport.top
348 footerTop = viewport.bottom
349 maxHeight = viewport.height / 3
350 minWidth = Math.min(viewport.width / 2, 800)
351
352 # Restricting the candidates for headers and footers to the most likely set of
353 # elements results in a noticeable performance boost.
354 candidates = window.document.querySelectorAll(
355 'div, ul, nav, header, footer, section'
356 )
357
358 for candidate in candidates
359 rect = candidate.getBoundingClientRect()
360 continue unless rect.height <= maxHeight and rect.width >= minWidth
361 # Checking for `position: fixed;` is the absolutely most expensive
362 # operation, so that is done last.
363 switch
364 when rect.top <= headerBottom and rect.bottom > headerBottom and
365 isPositionFixed(candidate)
366 headerBottom = rect.bottom
367 when rect.bottom >= footerTop and rect.top < footerTop and
368 isPositionFixed(candidate)
369 footerTop = rect.top
370
371 return Math.min(element.clientHeight, footerTop - headerBottom)
372
373 getWindowViewport = (window) ->
374 {
375 clientWidth, clientHeight # Viewport size excluding scrollbars, usually.
376 scrollWidth, scrollHeight
377 } = window.document.documentElement
378 {innerWidth, innerHeight} = window # Viewport size including scrollbars.
379 # We don’t want markers to cover the scrollbars, so we should use
380 # `clientWidth` and `clientHeight`. However, when there are no scrollbars
381 # those might be too small. Then we use `innerWidth` and `innerHeight`.
382 width = if scrollWidth > innerWidth then clientWidth else innerWidth
383 height = if scrollHeight > innerHeight then clientHeight else innerHeight
384 return {
385 left: 0
386 top: 0
387 right: width
388 bottom: height
389 width
390 height
391 }
392
393 injectTemporaryPopup = (document, contents) ->
394 popup = document.createElement('menupopup')
395 popup.appendChild(contents)
396 document.getElementById('mainPopupSet').appendChild(popup)
397 listenOnce(popup, 'popuphidden', popup.remove.bind(popup))
398 return popup
399
400 insertText = (input, value) ->
401 {selectionStart, selectionEnd} = input
402 input.value =
403 input.value[0...selectionStart] + value + input.value[selectionEnd..]
404 input.selectionStart = input.selectionEnd = selectionStart + value.length
405
406 isDetached = (element) ->
407 return not element.ownerDocument?.documentElement?.contains?(element)
408
409 isPositionFixed = (element) ->
410 computedStyle = element.ownerGlobal.getComputedStyle(element)
411 return computedStyle?.getPropertyValue('position') == 'fixed'
412
413 querySelectorAllDeep = (window, selector) ->
414 elements = Array.from(window.document.querySelectorAll(selector))
415 for frame in window.frames
416 elements.push(querySelectorAllDeep(frame, selector)...)
417 return elements
418
419 scroll = (element, args) ->
420 {method, type, directions, amounts, properties, adjustment, smooth} = args
421 options = {}
422 for direction, index in directions
423 amount = amounts[index]
424 options[direction] = -Math.sign(amount) * adjustment + switch type
425 when 'lines'
426 amount
427 when 'pages'
428 amount *
429 if properties[index] == 'clientHeight'
430 getViewportCappedClientHeight(element)
431 else
432 element[properties[index]]
433 when 'other'
434 Math.min(amount, element[properties[index]])
435 options.behavior = 'smooth' if smooth
436 element[method](options)
437
438 setAttributes = (element, attributes) ->
439 for attribute, value of attributes
440 element.setAttribute(attribute, value)
441 return
442
443 setHover = (element, hover) ->
444 method = if hover then 'addPseudoClassLock' else 'removePseudoClassLock'
445 while element.parentElement
446 nsIDomUtils[method](element, ':hover')
447 element = element.parentElement
448 return
449
450
451
452 # Language helpers
453
454 class Counter
455 constructor: ({start: @value = 0, @step = 1}) ->
456 tick: -> @value += @step
457
458 class EventEmitter
459 constructor: ->
460 @listeners = {}
461
462 on: (event, listener) ->
463 (@listeners[event] ?= new Set()).add(listener)
464
465 off: (event, listener) ->
466 @listeners[event]?.delete(listener)
467
468 emit: (event, data) ->
469 @listeners[event]?.forEach((listener) ->
470 listener(data)
471 )
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 openTab = (window, url, options) ->
562 {gBrowser} = window
563 window.TreeStyleTabService?.readyToOpenChildTab(gBrowser.selectedTab)
564 gBrowser.loadOneTab(url, options)
565
566 writeToClipboard = (text) -> nsIClipboardHelper.copyString(text)
567
568
569
570 module.exports = {
571 isActivatable
572 isAdjustable
573 isContentEditable
574 isFocusable
575 isIframeEditor
576 isIgnoreModeFocusType
577 isProperLink
578 isTextInputElement
579 isTypingElement
580
581 getActiveElement
582 blurActiveElement
583 blurActiveBrowserElement
584 focusElement
585 getFocusType
586
587 listen
588 listenOnce
589 onRemoved
590 suppressEvent
591 simulateMouseEvents
592
593 area
594 containsDeep
595 createBox
596 getViewportCappedClientHeight
597 getWindowViewport
598 injectTemporaryPopup
599 insertText
600 isDetached
601 isPositionFixed
602 querySelectorAllDeep
603 scroll
604 setAttributes
605 setHover
606
607 Counter
608 EventEmitter
609 has
610 includes
611 nextTick
612 regexEscape
613 removeDuplicates
614 removeDuplicateCharacters
615 interval
616
617 expandPath
618 formatError
619 getCurrentLocation
620 getCurrentWindow
621 hasEventListeners
622 loadCss
623 observe
624 openPopup
625 openTab
626 writeToClipboard
627 }
Imprint / Impressum