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