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