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