]> git.gir.st - VimFx.git/blob - extension/lib/utils.coffee
Fix the "URL popup" not appearing when using `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 # Copyright Alan Wu 2016.
6 #
7 # This file is part of VimFx.
8 #
9 # VimFx is free software: you can redistribute it and/or modify
10 # it under the terms of the GNU General Public License as published by
11 # the Free Software Foundation, either version 3 of the License, or
12 # (at your option) any later version.
13 #
14 # VimFx is distributed in the hope that it will be useful,
15 # but WITHOUT ANY WARRANTY; without even the implied warranty of
16 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 # GNU General Public License for more details.
18 #
19 # You should have received a copy of the GNU General Public License
20 # along with VimFx. If not, see <http://www.gnu.org/licenses/>.
21 ###
22
23 # This file contains lots of different helper functions.
24
25 {OS} = Components.utils.import('resource://gre/modules/osfile.jsm', {})
26
27 nsIClipboardHelper = Cc['@mozilla.org/widget/clipboardhelper;1']
28 .getService(Ci.nsIClipboardHelper)
29 nsIDomUtils = Cc['@mozilla.org/inspector/dom-utils;1']
30 .getService(Ci.inIDOMUtils)
31 nsIEventListenerService = Cc['@mozilla.org/eventlistenerservice;1']
32 .getService(Ci.nsIEventListenerService)
33 nsIFocusManager = Cc['@mozilla.org/focus-manager;1']
34 .getService(Ci.nsIFocusManager)
35 nsIStyleSheetService = Cc['@mozilla.org/content/style-sheet-service;1']
36 .getService(Ci.nsIStyleSheetService)
37 nsIWindowMediator = Cc['@mozilla.org/appshell/window-mediator;1']
38 .getService(Ci.nsIWindowMediator)
39
40 # For XUL, `instanceof` checks are often better than `.localName` checks,
41 # because some of the below interfaces are extended by many elements.
42 XULDocument = Ci.nsIDOMXULDocument
43 XULButtonElement = Ci.nsIDOMXULButtonElement
44 XULControlElement = Ci.nsIDOMXULControlElement
45 XULMenuListElement = Ci.nsIDOMXULMenuListElement
46 XULTextBoxElement = Ci.nsIDOMXULTextBoxElement
47
48 # Full chains of events for different mouse actions. Note: 'click' is fired
49 # by Firefox automatically after 'mousedown' and 'mouseup'.
50 EVENTS_CLICK = ['mousedown', 'mouseup']
51 EVENTS_CLICK_XUL = ['click', 'command']
52 EVENTS_HOVER_START = ['mouseover', 'mouseenter', 'mousemove']
53 EVENTS_HOVER_END = ['mouseout', 'mouseleave']
54
55
56
57 # Element classification helpers
58
59 isActivatable = (element) ->
60 return element.localName in ['a', 'button'] or
61 (element.localName == 'input' and element.type in [
62 'button', 'submit', 'reset', 'image'
63 ]) or
64 element instanceof XULButtonElement
65
66 isAdjustable = (element) ->
67 return element.localName == 'input' and element.type in [
68 'checkbox', 'radio', 'file', 'color'
69 'date', 'time', 'datetime', 'datetime-local', 'month', 'week'
70 ] or
71 element.localName in ['video', 'audio', 'embed', 'object'] or
72 element instanceof XULControlElement or
73 # Custom video players.
74 includes(element.className, 'video') or
75 includes(element.className, 'player') or
76 # Youtube special case.
77 element.classList?.contains('ytp-button') or
78 # Allow navigating object inspection trees in th devtools with the
79 # arrow keys, even if the arrow keys are used as VimFx shortcuts.
80 isDevtoolsElement(element)
81
82 isContentEditable = (element) ->
83 return element.isContentEditable or
84 isIframeEditor(element) or
85 # Google.
86 element.getAttribute?('g_editable') == 'true' or
87 element.ownerDocument?.body?.getAttribute?('g_editable') == 'true' or
88 # Codeacademy terminals.
89 element.classList?.contains('real-terminal')
90
91 isDevtoolsElement = (element) ->
92 return false unless element.ownerGlobal
93 return Array.some(element.ownerGlobal.top.frames, isDevtoolsWindow)
94
95 isDevtoolsWindow = (window) ->
96 return window.location?.href in [
97 'about:devtools-toolbox'
98 'chrome://devtools/content/framework/toolbox.xul'
99 ]
100
101 isFocusable = (element) ->
102 return element.tabIndex > -1 and
103 not (element.localName?.endsWith?('box') and
104 element.localName != 'checkbox') and
105 element.localName not in ['tabs', 'menuitem', 'menuseparator']
106
107 isIframeEditor = (element) ->
108 return false unless element.localName == 'body'
109 return \
110 # Etherpad.
111 element.id == 'innerdocbody' or
112 # XpressEditor.
113 (element.classList?.contains('xe_content') and
114 element.classList?.contains('editable')) or
115 # vBulletin.
116 element.classList?.contains('wysiwyg') or
117 # The wasavi extension.
118 element.hasAttribute?('data-wasavi-state')
119
120 isIgnoreModeFocusType = (element) ->
121 return \
122 # The wasavi extension.
123 element.hasAttribute?('data-wasavi-state') or
124 element.closest?('#wasavi_container') or
125 # CodeMirror in Vim mode.
126 (element.localName == 'textarea' and
127 element.closest?('.CodeMirror') and _hasVimEventListener(element))
128
129 # CodeMirror’s Vim mode is really sucky to detect. The only way seems to be to
130 # check if the there are any event listener functions with Vim-y words in them.
131 _hasVimEventListener = (element) ->
132 for listener in nsIEventListenerService.getListenerInfoFor(element)
133 if listener.listenerObject and
134 /\bvim\b|insertmode/i.test(String(listener.listenerObject))
135 return true
136 return false
137
138 isProperLink = (element) ->
139 # `.getAttribute` is used below instead of `.hasAttribute` to exclude `<a
140 # href="">`s used as buttons on some sites.
141 return element.getAttribute?('href') and
142 (element.localName == 'a' or
143 element.ownerDocument instanceof XULDocument) and
144 not element.href?.endsWith?('#') and
145 not element.href?.endsWith?('#?') and
146 not element.href?.startsWith?('javascript:')
147
148 isTextInputElement = (element) ->
149 return (element.localName == 'input' and element.type in [
150 'text', 'search', 'tel', 'url', 'email', 'password', 'number'
151 ]) or
152 element.localName == 'textarea' or
153 element instanceof XULTextBoxElement or
154 isContentEditable(element)
155
156 isTypingElement = (element) ->
157 return isTextInputElement(element) or
158 # `<select>` elements can also receive text input: You may type the
159 # text of an item to select it.
160 element.localName == 'select' or
161 element instanceof XULMenuListElement
162
163
164
165 # Active/focused element helpers
166
167 # NOTE: In frame scripts, `document.activeElement` may be `null` when the page
168 # is loading. Therefore always check if anything was returned, such as:
169 #
170 # return unless activeElement = utils.getActiveElement(window)
171 getActiveElement = (window) ->
172 {activeElement} = window.document
173 return null unless activeElement
174 # If the active element is a frame, recurse into it. The easiest way to detect
175 # a frame that works both in browser UI and in web page content is to check
176 # for the presence of `.contentWindow`. However, in non-multi-process,
177 # `<browser>` (sometimes `<xul:browser>`) elements have a `.contentWindow`
178 # pointing to the web page content `window`, which we don’t want to recurse
179 # into. The problem is that there are _some_ `<browser>`s which we _want_ to
180 # recurse into, such as the sidebar (for instance the history sidebar), and
181 # dialogs in `about:preferences`. Checking the `contextmenu` attribute seems
182 # to be a reliable test, catching both the main tab `<browser>`s and bookmarks
183 # opened in the sidebar.
184 if (activeElement.localName == 'browser' and
185 activeElement.getAttribute?('contextmenu') == 'contentAreaContextMenu') or
186 not activeElement.contentWindow
187 return activeElement
188 else
189 return getActiveElement(activeElement.contentWindow)
190
191 blurActiveElement = (window) ->
192 # Blurring a frame element also blurs any active elements inside it. Recursing
193 # into the frames and blurring the “real” active element directly would give
194 # focus to the `<body>` of its containing frame, while blurring the top-most
195 # frame gives focus to the top-most `<body>`. This allows to blur fancy text
196 # editors which use an `<iframe>` as their text area.
197 window.document.activeElement?.blur()
198
199 blurActiveBrowserElement = (vim) ->
200 # - Blurring in the next tick allows to pass `<escape>` to the location bar to
201 # reset it, for example.
202 # - Focusing the current browser afterwards allows to pass `<escape>` as well
203 # as unbound keys to the page. However, focusing the browser also triggers
204 # focus events on `document` and `window` in the current page. Many pages
205 # re-focus some text input on those events, making it impossible to blur
206 # those! Therefore we tell the frame script to suppress those events.
207 {window} = vim
208 activeElement = getActiveElement(window)
209 vim._send('browserRefocus')
210 nextTick(window, ->
211 activeElement.blur()
212 window.gBrowser.selectedBrowser.focus()
213 )
214
215 # Focus an element and tell Firefox that the focus happened because of a user
216 # action (not just because some random programmatic focus). `.FLAG_BYKEY` might
217 # look more appropriate, but it unconditionally selects all text, which
218 # `.FLAG_BYMOUSE` does not.
219 focusElement = (element, options = {}) ->
220 nsIFocusManager.setFocus(element, options.flag ? 'FLAG_BYMOUSE')
221 element.select?() if options.select
222
223 getFocusType = (element) -> switch
224 when isIgnoreModeFocusType(element)
225 'ignore'
226 when isTypingElement(element)
227 if element.closest?('findbar') then 'findbar' else 'editable'
228 when isActivatable(element)
229 'activatable'
230 when isAdjustable(element)
231 'adjustable'
232 else
233 'none'
234
235
236
237 # Event helpers
238
239 listen = (element, eventName, listener, useCapture = true) ->
240 element.addEventListener(eventName, listener, useCapture)
241 module.onShutdown(->
242 element.removeEventListener(eventName, listener, useCapture)
243 )
244
245 listenOnce = (element, eventName, listener, useCapture = true) ->
246 fn = (event) ->
247 listener(event)
248 element.removeEventListener(eventName, fn, useCapture)
249 listen(element, eventName, fn, useCapture)
250
251 onRemoved = (element, fn) ->
252 window = element.ownerGlobal
253
254 disconnected = false
255 disconnect = ->
256 return if disconnected
257 disconnected = true
258 mutationObserver.disconnect() unless Cu.isDeadWrapper(mutationObserver)
259
260 mutationObserver = new window.MutationObserver((changes) ->
261 for change in changes then for removedElement in change.removedNodes
262 if removedElement.contains?(element)
263 disconnect()
264 fn()
265 return
266 )
267 mutationObserver.observe(window.document.documentElement, {
268 childList: true
269 subtree: true
270 })
271 module.onShutdown(disconnect)
272
273 return disconnect
274
275 suppressEvent = (event) ->
276 event.preventDefault()
277 event.stopPropagation()
278
279 simulateMouseEvents = (element, sequence) ->
280 window = element.ownerGlobal
281 rect = element.getBoundingClientRect()
282
283 eventSequence = switch sequence
284 when 'click'
285 EVENTS_CLICK
286 when 'click-xul'
287 EVENTS_CLICK_XUL
288 when 'hover-start'
289 EVENTS_HOVER_START
290 when 'hover-end'
291 EVENTS_HOVER_END
292 else
293 sequence
294
295 for type in eventSequence
296 buttonNum = if type in EVENTS_CLICK then 1 else 0
297 mouseEvent = new window.MouseEvent(type, {
298 # Let the event bubble in order to trigger delegated event listeners.
299 bubbles: type not in ['mouseenter', 'mouseleave']
300 # Make the event cancelable so that `<a href="#">` can be used as a
301 # JavaScript-powered button without scrolling to the top of the page.
302 cancelable: type not in ['mouseenter', 'mouseleave']
303 # These properties are just here for mimicing a real click as much as
304 # possible.
305 buttons: buttonNum
306 detail: buttonNum
307 view: window
308 # `page{X,Y}` are set automatically to the correct values when setting
309 # `client{X,Y}`. `{offset,layer,movement}{X,Y}` are not worth the trouble
310 # to set.
311 clientX: rect.left
312 clientY: rect.top
313 # To exactly calculate `screen{X,Y}` one has to to check where the web
314 # page content area is inside the browser chrome and go through all parent
315 # frames as well. This is good enough. YAGNI for now.
316 screenX: window.screenX + rect.left
317 screenY: window.screenY + rect.top
318 })
319 if type == 'mousemove'
320 # If the below technique is used for this event, the “URL popup” (shown
321 # when hovering or focusing links) does not appear.
322 element.dispatchEvent(mouseEvent)
323 else
324 # The last `true` below marks the event as trusted, which some APIs
325 # require, such as `requestFullscreen()`.
326 # (`element.dispatchEvent(mouseEvent)` is not able to do this.)
327 window
328 .QueryInterface(Ci.nsIInterfaceRequestor)
329 .getInterface(Ci.nsIDOMWindowUtils)
330 .dispatchDOMEventViaPresShell(element, mouseEvent, true)
331
332 return
333
334
335
336 # DOM helpers
337
338 area = (element) ->
339 return element.clientWidth * element.clientHeight
340
341 checkElementOrAncestor = (element, fn) ->
342 window = element.ownerGlobal
343 while element.parentElement
344 return true if fn(element)
345 element = element.parentElement
346 return false
347
348 clearSelectionDeep = (window) ->
349 # The selection might be `null` in hidden frames.
350 selection = window.getSelection()
351 selection?.removeAllRanges()
352 for frame in window.frames
353 clearSelectionDeep(frame)
354 # Allow parents to re-gain control of text selection.
355 frame.frameElement.blur()
356 return
357
358 containsDeep = (parent, element) ->
359 parentWindow = parent.ownerGlobal
360 elementWindow = element.ownerGlobal
361
362 # Owner windows might be missing when opening the devtools.
363 while elementWindow and parentWindow and
364 elementWindow != parentWindow and elementWindow.top != elementWindow
365 element = elementWindow.frameElement
366 elementWindow = element.ownerGlobal
367
368 return parent.contains(element)
369
370 createBox = (document, className = '', parent = null, text = null) ->
371 box = document.createElement('box')
372 box.className = "#{className} vimfx-box"
373 box.textContent = text if text?
374 parent.appendChild(box) if parent?
375 return box
376
377 # In quirks mode (when the page lacks a doctype), such as on Hackernews,
378 # `<body>` is considered the root element rather than `<html>`.
379 getRootElement = (document) ->
380 if document.compatMode == 'BackCompat' and document.body?
381 return document.body
382 else
383 return document.documentElement
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 isDetached = (element) ->
399 return not element.ownerDocument?.documentElement?.contains?(element)
400
401 isNonEmptyTextNode = (node) ->
402 return node.nodeType == 3 and node.data.trim() != ''
403
404 isPositionFixed = (element) ->
405 computedStyle = element.ownerGlobal.getComputedStyle(element)
406 return computedStyle?.getPropertyValue('position') == 'fixed'
407
408 querySelectorAllDeep = (window, selector) ->
409 elements = Array.from(window.document.querySelectorAll(selector))
410 for frame in window.frames
411 elements.push(querySelectorAllDeep(frame, selector)...)
412 return elements
413
414 setAttributes = (element, attributes) ->
415 for attribute, value of attributes
416 element.setAttribute(attribute, value)
417 return
418
419 setHover = (element, hover) ->
420 method = if hover then 'addPseudoClassLock' else 'removePseudoClassLock'
421 while element.parentElement
422 nsIDomUtils[method](element, ':hover')
423 element = element.parentElement
424 return
425
426
427
428 # Language helpers
429
430 class Counter
431 constructor: ({start: @value = 0, @step = 1}) ->
432 tick: -> @value += @step
433
434 class EventEmitter
435 constructor: ->
436 @listeners = {}
437
438 on: (event, listener) ->
439 (@listeners[event] ?= new Set()).add(listener)
440
441 off: (event, listener) ->
442 @listeners[event]?.delete(listener)
443
444 emit: (event, data) ->
445 @listeners[event]?.forEach((listener) ->
446 listener(data)
447 )
448
449 # Returns `[nonMatch, adjacentMatchAfter]`, where `adjacentMatchAfter - nonMatch
450 # == 1`. `fn(n)` is supposed to return `false` for `n <= nonMatch` and `true`
451 # for `n >= adjacentMatchAfter`. Both `nonMatch` and `adjacentMatchAfter` may be
452 # `null` if they cannot be found. Otherwise they’re in the range `min <= n <=
453 # max`. `[null, null]` is returned in non-sensical cases. This function is
454 # intended to be used as a faster alternative to something like this:
455 #
456 # adjacentMatchAfter = null
457 # for n in [min..max]
458 # if fn(n)
459 # adjacentMatchAfter = n
460 # break
461 bisect = (min, max, fn) ->
462 return [null, null] unless max - min >= 0 and min % 1 == 0 and max % 1 == 0
463
464 while max - min > 1
465 mid = min + (max - min) // 2
466 match = fn(mid)
467 if match
468 max = mid
469 else
470 min = mid
471
472 matchMin = fn(min)
473 matchMax = fn(max)
474
475 return switch
476 when matchMin and matchMax
477 [null, min]
478 when not matchMin and not matchMax
479 [max, null]
480 when not matchMin and matchMax
481 [min, max]
482 else
483 [null, null]
484
485 has = (obj, prop) -> Object::hasOwnProperty.call(obj, prop)
486
487 # Check if `search` exists in `string` (case insensitively). Returns `false` if
488 # `string` doesn’t exist or isn’t a string, such as `<SVG element>.className`.
489 includes = (string, search) ->
490 return false unless typeof string == 'string'
491 return string.toLowerCase().includes(search)
492
493 nextTick = (window, fn) -> window.setTimeout((-> fn()) , 0)
494
495 regexEscape = (s) -> s.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
496
497 removeDuplicates = (array) -> Array.from(new Set(array))
498
499 # Remove duplicate characters from string (case insensitive).
500 removeDuplicateCharacters = (str) ->
501 return removeDuplicates( str.toLowerCase().split('') ).join('')
502
503 # Calls `fn` repeatedly, with at least `interval` ms between each call.
504 interval = (window, interval, fn) ->
505 stopped = false
506 currentIntervalId = null
507 next = ->
508 return if stopped
509 currentIntervalId = window.setTimeout((-> fn(next)), interval)
510 clearInterval = ->
511 stopped = true
512 window.clearTimeout(currentIntervalId)
513 next()
514 return clearInterval
515
516
517
518 # Misc helpers
519
520 expandPath = (path) ->
521 if path.startsWith('~/') or path.startsWith('~\\')
522 return OS.Constants.Path.homeDir + path[1..]
523 else
524 return path
525
526 formatError = (error) ->
527 stack = String(error.stack?.formattedStack ? error.stack ? '')
528 .split('\n')
529 .filter((line) -> line.includes('.xpi!'))
530 .map((line) -> ' ' + line.replace(/(?:\/<)*@.+\.xpi!/g, '@'))
531 .join('\n')
532 return "#{error}\n#{stack}"
533
534 getCurrentLocation = ->
535 window = getCurrentWindow()
536 return new window.URL(window.gBrowser.selectedBrowser.currentURI.spec)
537
538 getCurrentWindow = -> nsIWindowMediator.getMostRecentWindow('navigator:browser')
539
540 hasEventListeners = (element, type) ->
541 for listener in nsIEventListenerService.getListenerInfoFor(element)
542 if listener.listenerObject and listener.type == type
543 return true
544 return false
545
546 loadCss = (uriString) ->
547 uri = Services.io.newURI(uriString, null, null)
548 method = nsIStyleSheetService.AUTHOR_SHEET
549 unless nsIStyleSheetService.sheetRegistered(uri, method)
550 nsIStyleSheetService.loadAndRegisterSheet(uri, method)
551 module.onShutdown(->
552 nsIStyleSheetService.unregisterSheet(uri, method)
553 )
554
555 observe = (topic, observer) ->
556 observer = {observe: observer} if typeof observer == 'function'
557 Services.obs.addObserver(observer, topic, false)
558 module.onShutdown(->
559 Services.obs.removeObserver(observer, topic, false)
560 )
561
562 openPopup = (popup) ->
563 window = popup.ownerGlobal
564 # Show the popup so it gets a height and width.
565 popup.openPopupAtScreen(0, 0)
566 # Center the popup inside the window.
567 popup.moveTo(
568 window.screenX + window.outerWidth / 2 - popup.clientWidth / 2,
569 window.screenY + window.outerHeight / 2 - popup.clientHeight / 2
570 )
571
572 writeToClipboard = (text) -> nsIClipboardHelper.copyString(text)
573
574
575
576 module.exports = {
577 isActivatable
578 isAdjustable
579 isContentEditable
580 isDevtoolsElement
581 isDevtoolsWindow
582 isFocusable
583 isIframeEditor
584 isIgnoreModeFocusType
585 isProperLink
586 isTextInputElement
587 isTypingElement
588
589 getActiveElement
590 blurActiveElement
591 blurActiveBrowserElement
592 focusElement
593 getFocusType
594
595 listen
596 listenOnce
597 onRemoved
598 suppressEvent
599 simulateMouseEvents
600
601 area
602 checkElementOrAncestor
603 clearSelectionDeep
604 containsDeep
605 createBox
606 getRootElement
607 injectTemporaryPopup
608 insertText
609 isDetached
610 isNonEmptyTextNode
611 isPositionFixed
612 querySelectorAllDeep
613 setAttributes
614 setHover
615
616 Counter
617 EventEmitter
618 bisect
619 has
620 includes
621 nextTick
622 regexEscape
623 removeDuplicates
624 removeDuplicateCharacters
625 interval
626
627 expandPath
628 formatError
629 getCurrentLocation
630 getCurrentWindow
631 hasEventListeners
632 loadCss
633 observe
634 openPopup
635 writeToClipboard
636 }
Imprint / Impressum