]> git.gir.st - VimFx.git/blob - extension/lib/utils.coffee
Unify `f` in XUL with `zF`
[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. This seems to be the only way to detect such elements. There is one
167 # exception: The sidebar (such as the history sidebar). It is also a
168 # `<browser>` with `.contentWindow`, but we _do_ want to recurse into it. Note
169 # that bookmarks can be opened in the sidebar, but their content is inside
170 # another `<browser>` inside `<browser#sidebar>` so there’s no need to worry
171 # about that case.
172 if (activeElement.localName != 'browser' or activeElement.id == 'sidebar') and
173 activeElement.contentWindow
174 return getActiveElement(activeElement.contentWindow)
175 else
176 return activeElement
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 'editable'
215 when isActivatable(element)
216 'activatable'
217 when isAdjustable(element)
218 'adjustable'
219 # TODO: Remove when Tab Groups have been removed.
220 when element.ownerGlobal?.TabView?.isVisible?()
221 'other'
222 else
223 'none'
224
225
226
227 # Event helpers
228
229 listen = (element, eventName, listener, useCapture = true) ->
230 element.addEventListener(eventName, listener, useCapture)
231 module.onShutdown(->
232 element.removeEventListener(eventName, listener, useCapture)
233 )
234
235 listenOnce = (element, eventName, listener, useCapture = true) ->
236 fn = (event) ->
237 listener(event)
238 element.removeEventListener(eventName, fn, useCapture)
239 listen(element, eventName, fn, useCapture)
240
241 onRemoved = (element, fn) ->
242 window = element.ownerGlobal
243
244 disconnected = false
245 disconnect = ->
246 return if disconnected
247 disconnected = true
248 mutationObserver.disconnect() unless Cu.isDeadWrapper(mutationObserver)
249
250 mutationObserver = new window.MutationObserver((changes) ->
251 for change in changes then for removedElement in change.removedNodes
252 if removedElement.contains?(element)
253 disconnect()
254 fn()
255 return
256 )
257 mutationObserver.observe(window.document.documentElement, {
258 childList: true
259 subtree: true
260 })
261 module.onShutdown(disconnect)
262
263 return disconnect
264
265 suppressEvent = (event) ->
266 event.preventDefault()
267 event.stopPropagation()
268
269 simulateMouseEvents = (element, sequence) ->
270 window = element.ownerGlobal
271 rect = element.getBoundingClientRect()
272
273 eventSequence = switch sequence
274 when 'click'
275 EVENTS_CLICK
276 when 'click-xul'
277 EVENTS_CLICK_XUL
278 when 'hover-start'
279 EVENTS_HOVER_START
280 when 'hover-end'
281 EVENTS_HOVER_END
282 else
283 sequence
284
285 for type in eventSequence
286 buttonNum = if type in EVENTS_CLICK then 1 else 0
287 mouseEvent = new window.MouseEvent(type, {
288 # Let the event bubble in order to trigger delegated event listeners.
289 bubbles: type not in ['mouseenter', 'mouseleave']
290 # Make the event cancelable so that `<a href="#">` can be used as a
291 # JavaScript-powered button without scrolling to the top of the page.
292 cancelable: type not in ['mouseenter', 'mouseleave']
293 # These properties are just here for mimicing a real click as much as
294 # possible.
295 buttons: buttonNum
296 detail: buttonNum
297 view: window
298 # `page{X,Y}` are set automatically to the correct values when setting
299 # `client{X,Y}`. `{offset,layer,movement}{X,Y}` are not worth the trouble
300 # to set.
301 clientX: rect.left
302 clientY: rect.top
303 # To exactly calculate `screen{X,Y}` one has to to check where the web
304 # page content area is inside the browser chrome and go through all parent
305 # frames as well. This is good enough. YAGNI for now.
306 screenX: window.screenX + rect.left
307 screenY: window.screenY + rect.top
308 })
309 element.dispatchEvent(mouseEvent)
310
311 return
312
313
314
315 # DOM helpers
316
317 area = (element) ->
318 return element.clientWidth * element.clientHeight
319
320 containsDeep = (parent, element) ->
321 parentWindow = parent.ownerGlobal
322 elementWindow = element.ownerGlobal
323
324 # Owner windows might be missing when opening the devtools.
325 while elementWindow and parentWindow and
326 elementWindow != parentWindow and elementWindow.top != elementWindow
327 element = elementWindow.frameElement
328 elementWindow = element.ownerGlobal
329
330 return parent.contains(element)
331
332 createBox = (document, className = '', parent = null, text = null) ->
333 box = document.createElement('box')
334 box.className = "#{className} vimfx-box"
335 box.textContent = text if text?
336 parent.appendChild(box) if parent?
337 return box
338
339 # Returns the minimum of `element.clientHeight` and the height of the viewport,
340 # taking fixed headers and footers into account. Adapted from Firefox’s source
341 # code for `<space>` scrolling (which is where the arbitrary constants below
342 # come from).
343 #
344 # coffeelint: disable=max_line_length
345 # <https://hg.mozilla.org/mozilla-central/file/4d75bd6fd234/layout/generic/nsGfxScrollFrame.cpp#l3829>
346 # coffeelint: enable=max_line_length
347 getViewportCappedClientHeight = (element) ->
348 window = element.ownerGlobal
349 viewport = getWindowViewport(window)
350 headerBottom = viewport.top
351 footerTop = viewport.bottom
352 maxHeight = viewport.height / 3
353 minWidth = Math.min(viewport.width / 2, 800)
354
355 # Restricting the candidates for headers and footers to the most likely set of
356 # elements results in a noticeable performance boost.
357 candidates = window.document.querySelectorAll(
358 'div, ul, nav, header, footer, section'
359 )
360
361 for candidate in candidates
362 rect = candidate.getBoundingClientRect()
363 continue unless rect.height <= maxHeight and rect.width >= minWidth
364 # Checking for `position: fixed;` is the absolutely most expensive
365 # operation, so that is done last.
366 switch
367 when rect.top <= headerBottom and rect.bottom > headerBottom and
368 isPositionFixed(candidate)
369 headerBottom = rect.bottom
370 when rect.bottom >= footerTop and rect.top < footerTop and
371 isPositionFixed(candidate)
372 footerTop = rect.top
373
374 return Math.min(element.clientHeight, footerTop - headerBottom)
375
376 getWindowViewport = (window) ->
377 {
378 clientWidth, clientHeight # Viewport size excluding scrollbars, usually.
379 scrollWidth, scrollHeight
380 } = window.document.documentElement
381 {innerWidth, innerHeight} = window # Viewport size including scrollbars.
382 # We don’t want markers to cover the scrollbars, so we should use
383 # `clientWidth` and `clientHeight`. However, when there are no scrollbars
384 # those might be too small. Then we use `innerWidth` and `innerHeight`.
385 width = if scrollWidth > innerWidth then clientWidth else innerWidth
386 height = if scrollHeight > innerHeight then clientHeight else innerHeight
387 return {
388 left: 0
389 top: 0
390 right: width
391 bottom: height
392 width
393 height
394 }
395
396 injectTemporaryPopup = (document, contents) ->
397 popup = document.createElement('menupopup')
398 popup.appendChild(contents)
399 document.getElementById('mainPopupSet').appendChild(popup)
400 listenOnce(popup, 'popuphidden', popup.remove.bind(popup))
401 return popup
402
403 insertText = (input, value) ->
404 {selectionStart, selectionEnd} = input
405 input.value =
406 input.value[0...selectionStart] + value + input.value[selectionEnd..]
407 input.selectionStart = input.selectionEnd = selectionStart + value.length
408
409 isDetached = (element) ->
410 return not element.ownerDocument?.documentElement?.contains?(element)
411
412 isPositionFixed = (element) ->
413 computedStyle = element.ownerGlobal.getComputedStyle(element)
414 return computedStyle?.getPropertyValue('position') == 'fixed'
415
416 querySelectorAllDeep = (window, selector) ->
417 elements = Array.from(window.document.querySelectorAll(selector))
418 for frame in window.frames
419 elements.push(querySelectorAllDeep(frame, selector)...)
420 return elements
421
422 scroll = (element, args) ->
423 {method, type, directions, amounts, properties, adjustment, smooth} = args
424 options = {}
425 for direction, index in directions
426 amount = amounts[index]
427 options[direction] = -Math.sign(amount) * adjustment + switch type
428 when 'lines'
429 amount
430 when 'pages'
431 amount *
432 if properties[index] == 'clientHeight'
433 getViewportCappedClientHeight(element)
434 else
435 element[properties[index]]
436 when 'other'
437 Math.min(amount, element[properties[index]])
438 options.behavior = 'smooth' if smooth
439 element[method](options)
440
441 setAttributes = (element, attributes) ->
442 for attribute, value of attributes
443 element.setAttribute(attribute, value)
444 return
445
446 setHover = (element, hover) ->
447 method = if hover then 'addPseudoClassLock' else 'removePseudoClassLock'
448 while element.parentElement
449 nsIDomUtils[method](element, ':hover')
450 element = element.parentElement
451 return
452
453
454
455 # Language helpers
456
457 class Counter
458 constructor: ({start: @value = 0, @step = 1}) ->
459 tick: -> @value += @step
460
461 class EventEmitter
462 constructor: ->
463 @listeners = {}
464
465 on: (event, listener) ->
466 (@listeners[event] ?= new Set()).add(listener)
467
468 off: (event, listener) ->
469 @listeners[event]?.delete(listener)
470
471 emit: (event, data) ->
472 @listeners[event]?.forEach((listener) ->
473 listener(data)
474 )
475
476 has = (obj, prop) -> Object::hasOwnProperty.call(obj, prop)
477
478 # Check if `search` exists in `string` (case insensitively). Returns `false` if
479 # `string` doesn’t exist or isn’t a string, such as `<SVG element>.className`.
480 includes = (string, search) ->
481 return false unless typeof string == 'string'
482 return string.toLowerCase().includes(search)
483
484
485 nextTick = (window, fn) -> window.setTimeout((-> fn()) , 0)
486
487 regexEscape = (s) -> s.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
488
489 removeDuplicates = (array) -> Array.from(new Set(array))
490
491 # Remove duplicate characters from string (case insensitive).
492 removeDuplicateCharacters = (str) ->
493 return removeDuplicates( str.toLowerCase().split('') ).join('')
494
495 # Calls `fn` repeatedly, with at least `interval` ms between each call.
496 interval = (window, interval, fn) ->
497 stopped = false
498 currentIntervalId = null
499 next = ->
500 return if stopped
501 currentIntervalId = window.setTimeout((-> fn(next)), interval)
502 clearInterval = ->
503 stopped = true
504 window.clearTimeout(currentIntervalId)
505 next()
506 return clearInterval
507
508
509
510 # Misc helpers
511
512 expandPath = (path) ->
513 if path.startsWith('~/') or path.startsWith('~\\')
514 return OS.Constants.Path.homeDir + path[1..]
515 else
516 return path
517
518 formatError = (error) ->
519 stack = String(error.stack?.formattedStack ? error.stack ? '')
520 .split('\n')
521 .filter((line) -> line.includes('.xpi!'))
522 .map((line) -> ' ' + line.replace(/(?:\/<)*@.+\.xpi!/g, '@'))
523 .join('\n')
524 return "#{error}\n#{stack}"
525
526 getCurrentLocation = ->
527 window = getCurrentWindow()
528 return new window.URL(window.gBrowser.selectedBrowser.currentURI.spec)
529
530 getCurrentWindow = -> nsIWindowMediator.getMostRecentWindow('navigator:browser')
531
532 hasEventListeners = (element, type) ->
533 for listener in nsIEventListenerService.getListenerInfoFor(element)
534 if listener.listenerObject and listener.type == type
535 return true
536 return false
537
538 loadCss = (uriString) ->
539 uri = Services.io.newURI(uriString, null, null)
540 method = nsIStyleSheetService.AUTHOR_SHEET
541 unless nsIStyleSheetService.sheetRegistered(uri, method)
542 nsIStyleSheetService.loadAndRegisterSheet(uri, method)
543 module.onShutdown(->
544 nsIStyleSheetService.unregisterSheet(uri, method)
545 )
546
547 observe = (topic, observer) ->
548 observer = {observe: observer} if typeof observer == 'function'
549 Services.obs.addObserver(observer, topic, false)
550 module.onShutdown(->
551 Services.obs.removeObserver(observer, topic, false)
552 )
553
554 openPopup = (popup) ->
555 window = popup.ownerGlobal
556 # Show the popup so it gets a height and width.
557 popup.openPopupAtScreen(0, 0)
558 # Center the popup inside the window.
559 popup.moveTo(
560 window.screenX + window.outerWidth / 2 - popup.clientWidth / 2,
561 window.screenY + window.outerHeight / 2 - popup.clientHeight / 2
562 )
563
564 openTab = (window, url, options) ->
565 {gBrowser} = window
566 window.TreeStyleTabService?.readyToOpenChildTab(gBrowser.selectedTab)
567 gBrowser.loadOneTab(url, options)
568
569 writeToClipboard = (text) -> nsIClipboardHelper.copyString(text)
570
571
572
573 module.exports = {
574 isActivatable
575 isAdjustable
576 isContentEditable
577 isFocusable
578 isIframeEditor
579 isIgnoreModeFocusType
580 isProperLink
581 isTextInputElement
582 isTypingElement
583
584 getActiveElement
585 blurActiveElement
586 blurActiveBrowserElement
587 focusElement
588 getFocusType
589
590 listen
591 listenOnce
592 onRemoved
593 suppressEvent
594 simulateMouseEvents
595
596 area
597 containsDeep
598 createBox
599 getViewportCappedClientHeight
600 getWindowViewport
601 injectTemporaryPopup
602 insertText
603 isDetached
604 isPositionFixed
605 querySelectorAllDeep
606 scroll
607 setAttributes
608 setHover
609
610 Counter
611 EventEmitter
612 has
613 includes
614 nextTick
615 regexEscape
616 removeDuplicates
617 removeDuplicateCharacters
618 interval
619
620 expandPath
621 formatError
622 getCurrentLocation
623 getCurrentWindow
624 hasEventListeners
625 loadCss
626 observe
627 openPopup
628 openTab
629 writeToClipboard
630 }
Imprint / Impressum