1 # This file contains lots of different helper functions.
3 {OS} = Components.utils.import('resource://gre/modules/osfile.jsm', {})
5 nsIClipboardHelper = Cc['@mozilla.org/widget/clipboardhelper;1']
6 .getService(Ci.nsIClipboardHelper)
7 nsIEventListenerService = Cc['@mozilla.org/eventlistenerservice;1']
8 .getService(Ci.nsIEventListenerService)
9 nsIFocusManager = Cc['@mozilla.org/focus-manager;1']
10 .getService(Ci.nsIFocusManager)
11 nsIStyleSheetService = Cc['@mozilla.org/content/style-sheet-service;1']
12 .getService(Ci.nsIStyleSheetService)
13 nsIWindowMediator = Cc['@mozilla.org/appshell/window-mediator;1']
14 .getService(Ci.nsIWindowMediator)
16 # For XUL, `instanceof` checks are often better than `.localName` checks,
17 # because some of the below interfaces are extended by many elements.
18 XULButtonElement = Ci.nsIDOMXULButtonElement
19 XULControlElement = Ci.nsIDOMXULControlElement
20 XULMenuListElement = Ci.nsIDOMXULMenuListElement
22 # Traverse the DOM upwards until we hit its containing document (most likely an
23 # HTMLDocument or (<=fx68) XULDocument) or the ShadowRoot.
24 getDocument = (e) -> if e.parentNode? then arguments.callee(e.parentNode) else e
26 isInShadowRoot = (element) ->
27 ShadowRoot? and getDocument(element) instanceof ShadowRoot
29 isXULElement = (element) ->
30 XUL_NS = 'http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'
31 element.namespaceURI == XUL_NS
33 # Full chains of events for different mouse actions. Note: 'click' is fired
34 # by Firefox automatically after 'mousedown' and 'mouseup'. Similarly,
35 # 'command' is fired automatically after 'click' on xul pages.
36 EVENTS_CLICK = ['mousedown', 'mouseup']
37 EVENTS_CLICK_XUL = ['click']
38 EVENTS_CONTEXT = ['contextmenu']
39 EVENTS_HOVER_START = ['mouseover', 'mouseenter', 'mousemove']
40 EVENTS_HOVER_END = ['mouseout', 'mouseleave']
44 # Element classification helpers
46 hasMarkableTextNode = (element) ->
47 return Array.prototype.some.call(element.childNodes, (node) ->
48 # Ignore whitespace-only text nodes, and single-letter ones (which are
49 # common in many syntax highlighters).
50 return node.nodeType == 3 and node.data.trim().length > 1
53 isActivatable = (element) ->
54 return element.localName in ['a', 'button'] or
55 (element.localName == 'input' and element.type in [
56 'button', 'submit', 'reset', 'image'
58 element instanceof XULButtonElement
60 isAdjustable = (element) ->
61 return element.localName == 'input' and element.type in [
62 'checkbox', 'radio', 'file', 'color'
63 'date', 'time', 'datetime', 'datetime-local', 'month', 'week'
65 element.localName in ['video', 'audio', 'embed', 'object'] or
66 element instanceof XULControlElement or
67 # Custom video players.
68 includes(element.className, 'video') or
69 includes(element.className, 'player') or
70 # Youtube special case.
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 isDevtoolsElement(element)
76 isContentEditable = (element) ->
77 return element.isContentEditable or
78 isIframeEditor(element) or
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')
85 isDevtoolsElement = (element) ->
86 return false unless element.ownerGlobal
87 return Array.prototype.some.call(
88 element.ownerGlobal.top.frames, isDevtoolsWindow
91 isDevtoolsWindow = (window) ->
92 # Note: this function is called for each frame by isDevtoolsElement. When
93 # called on an out-of-process iframe, accessing .href will fail with
94 # SecurityError; the `try` around it makes it `undefined` in such a case.
95 return (try window.location?.href) in [
96 'about:devtools-toolbox'
97 'chrome://devtools/content/framework/toolbox.xul'
98 'chrome://devtools/content/framework/toolbox.xhtml' # fx72+
101 isDockedDevtoolsElement = (element) ->
102 return element.ownerDocument.URL.startsWith('chrome://devtools/content/')
104 isFocusable = (element) ->
105 # Focusable elements have `.tabIndex > 1` (but not necessarily a
106 # `tabindex="…"` attribute) …
107 return (element.tabIndex > -1 or
108 # … or an explicit `tabindex="-1"` attribute (which means that it is
109 # focusable, but not reachable with `<tab>`).
110 element.getAttribute?('tabindex') == '-1') and
111 not (element.localName?.endsWith?('box') and
112 element.localName != 'checkbox') and
113 not (element.localName == 'toolbarbutton' and
114 element.parentNode?.localName == 'toolbarbutton') and
115 element.localName not in ['tabs', 'menuitem', 'menuseparator']
117 isIframeEditor = (element) ->
118 return false unless element.localName == 'body'
121 element.id == 'innerdocbody' or
123 (element.classList?.contains('xe_content') and
124 element.classList?.contains('editable')) or
126 element.classList?.contains('wysiwyg') or
128 element.classList?.contains('htmlarea-content-body') or
129 # The wasavi extension.
130 element.hasAttribute?('data-wasavi-state')
132 isIgnoreModeFocusType = (element) ->
134 # The wasavi extension.
135 element.hasAttribute?('data-wasavi-state') or
136 element.closest?('#wasavi_container') or
137 # CodeMirror in Vim mode.
138 (element.localName == 'textarea' and
139 element.closest?('.CodeMirror') and _hasVimEventListener(element))
141 # CodeMirror’s Vim mode is really sucky to detect. The only way seems to be to
142 # check if the there are any event listener functions with Vim-y words in them.
143 _hasVimEventListener = (element) ->
144 for listener in nsIEventListenerService.getListenerInfoFor(element)
145 if listener.listenerObject and
146 /\bvim\b|insertmode/i.test(String(listener.listenerObject))
150 isProperLink = (element) ->
151 # `.getAttribute` is used below instead of `.hasAttribute` to exclude `<a
152 # href="">`s used as buttons on some sites.
153 return element.getAttribute?('href') and
154 (element.localName == 'a' or
155 (isXULElement(element) and
156 element.localName == 'label' and
157 element.getAttribute('is') == 'text-link')) and
158 not element.href?.endsWith?('#') and
159 not element.href?.endsWith?('#?') and
160 not element.href?.startsWith?('javascript:')
162 isTextInputElement = (element) ->
163 return (element.localName == 'input' and element.type in [
164 'text', 'search', 'tel', 'url', 'email', 'password', 'number'
166 element.localName in [ 'textarea', 'textbox' ] or
167 isContentEditable(element)
169 isTypingElement = (element) ->
170 return isTextInputElement(element) or
171 # `<select>` elements can also receive text input: You may type the
172 # text of an item to select it.
173 element.localName == 'select' or
174 element instanceof XULMenuListElement
178 # Active/focused element helpers
180 blurActiveBrowserElement = (vim) ->
181 # - Blurring in the next tick allows to pass `<escape>` to the location bar to
182 # reset it, for example.
183 # - Focusing the current browser afterwards allows to pass `<escape>` as well
184 # as unbound keys to the page. However, focusing the browser also triggers
185 # focus events on `document` and `window` in the current page. Many pages
186 # re-focus some text input on those events, making it impossible to blur
187 # those! Therefore we tell the frame script to suppress those events.
189 activeElement = getActiveElement(window)
190 activeElement.closest('tabmodalprompt')?.abortPrompt()
191 vim._send('browserRefocus')
194 window.gBrowser.selectedBrowser.focus()
197 blurActiveElement = (window) ->
198 # Blurring a frame element also blurs any active elements inside it. Recursing
199 # into the frames and blurring the “real” active element directly would give
200 # focus to the `<body>` of its containing frame, while blurring the top-most
201 # frame gives focus to the top-most `<body>`. This allows to blur fancy text
202 # editors which use an `<iframe>` as their text area.
203 # Note that this trick does not work with Web Components; for them, recursing
205 if window.document.activeElement?.shadowRoot?
206 return getActiveElement(window)?.blur()
207 window.document.activeElement?.blur()
209 # Focus an element and tell Firefox that the focus happened because of a user
210 # action (not just because some random programmatic focus). `.FLAG_BYKEY` might
211 # look more appropriate, but it unconditionally selects all text, which
212 # `.FLAG_BYMOUSE` does not.
213 focusElement = (element, options = {}) ->
214 nsIFocusManager.setFocus(element, options.flag ? 'FLAG_BYMOUSE')
215 element.select?() if options.select
217 # NOTE: In frame scripts, `document.activeElement` may be `null` when the page
218 # is loading. Therefore always check if anything was returned, such as:
220 # return unless activeElement = utils.getActiveElement(window)
221 getActiveElement = (window) ->
222 {activeElement} = window.shadowRoot or window.document
223 return null unless activeElement
224 # If the active element is a frame, recurse into it. The easiest way to detect
225 # a frame that works both in browser UI and in web page content is to check
226 # for the presence of `.contentWindow`. However, in non-multi-process,
227 # `<browser>` (sometimes `<xul:browser>`) elements have a `.contentWindow`
228 # pointing to the web page content `window`, which we don’t want to recurse
229 # into. The problem is that there are _some_ `<browser>`s which we _want_ to
230 # recurse into, such as the sidebar (for instance the history sidebar), and
231 # dialogs in `about:preferences`. Checking the `contextmenu` attribute seems
232 # to be a reliable test, catching both the main tab `<browser>`s and bookmarks
233 # opened in the sidebar.
234 # We also want to recurse into the (open) shadow DOM of custom elements.
235 if activeElement.shadowRoot?
236 return getActiveElement(activeElement)
237 else if activeElement.contentWindow and
238 not (activeElement.localName == 'browser' and
239 activeElement.getAttribute?('contextmenu') == 'contentAreaContextMenu')
240 # with Fission enabled, the iframe might be located in a different process
241 # (oop). Then, recursing into it isn't possible (throws SecurityError).
242 return activeElement unless (try activeElement.contentWindow.document)
244 return getActiveElement(activeElement.contentWindow)
248 getFocusType = (element) -> switch
249 when element.tagName in ['FRAME', 'IFRAME'] and
250 not (try element.contentWindow.document)
251 # Encountered an out-of-process iframe, which we can't inspect. We fall
252 # back to insert mode, so any text inputs it may contain are still usable.
254 when isIgnoreModeFocusType(element)
256 when isTypingElement(element)
257 if element.closest?('findbar') then 'findbar' else 'editable'
258 when isActivatable(element)
260 when isAdjustable(element)
269 listen = (element, eventName, listener, useCapture = true) ->
270 element.addEventListener(eventName, listener, useCapture)
272 element.removeEventListener(eventName, listener, useCapture)
275 listenOnce = (element, eventName, listener, useCapture = true) ->
278 element.removeEventListener(eventName, fn, useCapture)
279 listen(element, eventName, fn, useCapture)
281 onRemoved = (element, fn) ->
282 window = element.ownerGlobal
286 return if disconnected
288 mutationObserver.disconnect() unless Cu.isDeadWrapper(mutationObserver)
290 mutationObserver = new window.MutationObserver((changes) ->
291 for change in changes then for removedElement in change.removedNodes
292 if removedElement.contains?(element)
297 mutationObserver.observe(window.document.documentElement, {
301 module.onShutdown(disconnect)
305 simulateMouseEvents = (element, sequence, browserOffset) ->
306 window = element.ownerGlobal
307 rect = element.getBoundingClientRect()
308 topOffset = getTopOffset(element)
310 eventSequence = switch sequence
324 for type in eventSequence
326 when type in EVENTS_CONTEXT
328 when type in EVENTS_CLICK
333 mouseEvent = new window.MouseEvent(type, {
334 # Let the event bubble in order to trigger delegated event listeners.
335 bubbles: type not in ['mouseenter', 'mouseleave']
336 # Make the event cancelable so that `<a href="#">` can be used as a
337 # JavaScript-powered button without scrolling to the top of the page.
338 cancelable: type not in ['mouseenter', 'mouseleave']
339 # These properties are just here for mimicing a real click as much as
344 # `page{X,Y}` are set automatically to the correct values when setting
345 # `client{X,Y}`. `{offset,layer,movement}{X,Y}` are not worth the trouble
348 clientY: rect.top + rect.height / 2
349 screenX: browserOffset.x + topOffset.x
350 screenY: browserOffset.y + topOffset.y + rect.height / 2
353 if type == 'mousemove'
354 # If the below technique is used for this event, the “URL popup” (shown
355 # when hovering or focusing links) does not appear.
356 element.dispatchEvent(mouseEvent)
357 else if isInShadowRoot(element)
358 # click events for links and other clickables inside the shadow DOM are
359 # caught by the callee (.click_marker_element()).
360 element.focus() if type == 'contextmenu' # for <input type=text>
361 element.dispatchEvent(mouseEvent)
364 (window.windowUtils.dispatchDOMEventViaPresShellForTesting or
365 window.windowUtils.dispatchDOMEventViaPresShell # < fx73
366 )(element, mouseEvent)
368 if error.result != Cr.NS_ERROR_UNEXPECTED
373 suppressEvent = (event) ->
374 event.preventDefault()
375 event.stopPropagation()
382 return element.clientWidth * element.clientHeight
384 checkElementOrAncestor = (element, fn) ->
385 window = element.ownerGlobal
386 while element.parentElement
387 return true if fn(element)
388 element = element.parentElement
391 clearSelectionDeep = (window, {blur = true} = {}) ->
392 # The selection might be `null` in hidden frames.
393 selection = window.getSelection()
394 selection?.removeAllRanges()
395 # Note: accessing frameElement fails on oop iframes (fission); skip those.
396 for frame in window.frames when (try frame.frameElement)
397 clearSelectionDeep(frame, {blur})
398 # Allow parents to re-gain control of text selection.
399 frame.frameElement.blur() if blur
402 containsDeep = (parent, element) ->
403 parentWindow = parent.ownerGlobal
404 elementWindow = element.ownerGlobal
406 # Owner windows might be missing when opening the devtools.
407 while elementWindow and parentWindow and
408 elementWindow != parentWindow and elementWindow.top != elementWindow
409 element = elementWindow.frameElement
410 elementWindow = element.ownerGlobal
412 return parent.contains(element)
414 createBox = (document, className = '', parent = null, text = null) ->
415 box = document.createElement('box')
416 box.className = "#{className} vimfx-box"
417 box.textContent = text if text?
418 parent.appendChild(box) if parent?
421 # In quirks mode (when the page lacks a doctype), such as on Hackernews,
422 # `<body>` is considered the root element rather than `<html>`.
423 getRootElement = (document) ->
424 if document.compatMode == 'BackCompat' and document.body?
427 return document.documentElement
429 getText = (element) ->
430 text = element.textContent or element.value or element.placeholder or ''
431 return text.trim().replace(/\s+/, ' ')
433 getTopOffset = (element) ->
434 window = element.ownerGlobal
436 {left: x, top: y} = element.getBoundingClientRect()
437 while window.frameElement
438 frame = window.frameElement
439 frameRect = frame.getBoundingClientRect()
443 computedStyle = frame.ownerGlobal.getComputedStyle(frame)
446 parseFloat(computedStyle.getPropertyValue('border-left-width')) +
447 parseFloat(computedStyle.getPropertyValue('padding-left'))
449 parseFloat(computedStyle.getPropertyValue('border-top-width')) +
450 parseFloat(computedStyle.getPropertyValue('padding-top'))
452 window = window.parent
455 injectTemporaryPopup = (document, contents) ->
456 popup = document.createElement('menupopup')
457 popup.appendChild(contents)
458 document.getElementById('mainPopupSet').appendChild(popup)
459 listenOnce(popup, 'popuphidden', popup.remove.bind(popup))
462 insertText = (input, value) ->
463 {selectionStart, selectionEnd} = input
465 input.value[0...selectionStart] + value + input.value[selectionEnd..]
466 input.selectionStart = input.selectionEnd = selectionStart + value.length
468 isDetached = (element) ->
469 return not element.ownerDocument?.documentElement?.contains?(element)
471 isNonEmptyTextNode = (node) ->
472 return node.nodeType == 3 and node.data.trim() != ''
474 querySelectorAllDeep = (window, selector) ->
475 elements = Array.from(window.document.querySelectorAll(selector))
476 for frame in window.frames
477 elements.push(querySelectorAllDeep(frame, selector)...)
480 selectAllSubstringMatches = (element, substring, {caseSensitive = true} = {}) ->
481 window = element.ownerGlobal
482 selection = window.getSelection()
483 {textContent} = element
485 format = (string) -> if caseSensitive then string else string.toLowerCase()
487 getAllNonOverlappingRangeOffsets(format(textContent), format(substring))
488 offsetsLength = offsets.length
489 return if offsetsLength == 0
493 [currentOffset] = offsets
494 searchIndex = currentOffset.start
497 walkTextNodes(element, (textNode) ->
498 {length} = textNode.data
499 return false if length == 0
501 while textIndex + length > searchIndex
503 range = window.document.createRange()
504 range.setStart(start.textNode, start.offset)
505 range.setEnd(textNode, currentOffset.end - textIndex)
506 selection.addRange(range)
509 return true if offsetsIndex >= offsetsLength
510 currentOffset = offsets[offsetsIndex]
513 searchIndex = currentOffset.start
516 start = {textNode, offset: currentOffset.start - textIndex}
517 searchIndex = currentOffset.end - 1
523 selectElement = (element) ->
524 window = element.ownerGlobal
525 selection = window.getSelection()
526 range = window.document.createRange()
527 range.selectNodeContents(element)
528 selection.addRange(range)
530 setAttributes = (element, attributes) ->
531 for attribute, value of attributes
532 element.setAttribute(attribute, value)
535 walkTextNodes = (element, fn) ->
536 for node in element.childNodes then switch node.nodeType
541 stop = walkTextNodes(node, fn)
550 constructor: ({start: @value = 0, @step = 1}) ->
551 tick: -> @value += @step
557 on: (event, listener) ->
558 (@listeners[event] ?= new Set()).add(listener)
560 off: (event, listener) ->
561 @listeners[event]?.delete(listener)
563 emit: (event, data) ->
564 @listeners[event]?.forEach((listener) ->
568 # Returns `[nonMatch, adjacentMatchAfter]`, where `adjacentMatchAfter - nonMatch
569 # == 1`. `fn(n)` is supposed to return `false` for `n <= nonMatch` and `true`
570 # for `n >= adjacentMatchAfter`. Both `nonMatch` and `adjacentMatchAfter` may be
571 # `null` if they cannot be found. Otherwise they’re in the range `min <= n <=
572 # max`. `[null, null]` is returned in non-sensical cases. This function is
573 # intended to be used as a faster alternative to something like this:
575 # adjacentMatchAfter = null
576 # for n in [min..max]
578 # adjacentMatchAfter = n
580 bisect = (min, max, fn) ->
581 return [null, null] unless max - min >= 0 and min % 1 == 0 and max % 1 == 0
584 mid = min + (max - min) // 2
595 when matchMin and matchMax
597 when not matchMin and not matchMax
599 when not matchMin and matchMax
604 getAllNonOverlappingRangeOffsets = (string, substring) ->
606 return [] if length == 0
609 lastOffset = {start: -Infinity, end: -Infinity}
613 index = string.indexOf(substring, index + 1)
615 if index > lastOffset.end
616 lastOffset = {start: index, end: index + length}
617 offsets.push(lastOffset)
619 lastOffset.end = index + length
623 has = (obj, prop) -> Object::hasOwnProperty.call(obj, prop)
625 # Check if `search` exists in `string` (case insensitively). Returns `false` if
626 # `string` doesn’t exist or isn’t a string, such as `<SVG element>.className`.
627 includes = (string, search) ->
628 return false unless typeof string == 'string'
629 return string.toLowerCase().includes(search)
631 # Calls `fn` repeatedly, with at least `interval` ms between each call.
632 interval = (window, interval, fn) ->
634 currentIntervalId = null
637 currentIntervalId = window.setTimeout((-> fn(next)), interval)
640 window.clearTimeout(currentIntervalId)
644 nextTick = (window, fn) -> window.setTimeout((-> fn()) , 0)
646 overlaps = (rectA, rectB) ->
648 Math.round(rectA.right) >= Math.round(rectB.left) and
649 Math.round(rectA.left) <= Math.round(rectB.right) and
650 Math.round(rectA.bottom) >= Math.round(rectB.top) and
651 Math.round(rectA.top) <= Math.round(rectB.bottom)
653 partition = (array, fn) ->
656 for item, index in array
657 if fn(item, index, array)
660 nonMatching.push(item)
661 return [matching, nonMatching]
663 regexEscape = (s) -> s.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
665 removeDuplicateChars = (string) -> removeDuplicates(string.split('')).join('')
667 removeDuplicates = (array) -> Array.from(new Set(array))
669 sum = (numbers) -> numbers.reduce(((sum, number) -> sum + number), 0)
675 expandPath = (path) ->
676 if path.startsWith('~/') or path.startsWith('~\\')
677 return OS.Constants.Path.homeDir + path[1..]
681 getCurrentLocation = ->
682 return unless window = getCurrentWindow()
683 return new window.URL(window.gBrowser.selectedBrowser.currentURI.spec)
685 # This function might return `null` on startup.
686 getCurrentWindow = -> nsIWindowMediator.getMostRecentWindow('navigator:browser')
688 # gBrowser getFindBar() used to return the findBar directly, but in recent
689 # versions it returns a promise. This function should be removed once these old
690 # versions are no longer supported.
691 getFindBar = (gBrowser) ->
692 promiseOrFindBar = gBrowser.getFindBar()
693 if promiseOrFindBar instanceof Promise
696 Promise.resolve(promiseOrFindBar)
698 hasEventListeners = (element, type) ->
699 for listener in nsIEventListenerService.getListenerInfoFor(element)
700 if listener.listenerObject and listener.type == type
704 loadCss = (uriString) ->
705 uri = Services.io.newURI(uriString, null, null)
706 method = nsIStyleSheetService.AUTHOR_SHEET
707 unless nsIStyleSheetService.sheetRegistered(uri, method)
708 nsIStyleSheetService.loadAndRegisterSheet(uri, method)
710 nsIStyleSheetService.unregisterSheet(uri, method)
713 observe = (topic, observer) ->
714 observer = {observe: observer} if typeof observer == 'function'
715 Services.obs.addObserver(observer, topic, false)
717 Services.obs.removeObserver(observer, topic, false)
720 # Try to open a button’s dropdown menu, if any.
721 openDropdown = (element) ->
722 if isXULElement(element) and
723 element.getAttribute?('type') == 'menu' and
724 element.open == false # Only change `.open` if it is already a boolean.
727 openPopup = (popup) ->
728 window = popup.ownerGlobal
729 # Show the popup so it gets a height and width.
730 popup.openPopupAtScreen(0, 0)
731 # Center the popup inside the window.
733 window.screenX + window.outerWidth / 2 - popup.clientWidth / 2,
734 window.screenY + window.outerHeight / 2 - popup.clientHeight / 2
737 writeToClipboard = (text) -> nsIClipboardHelper.copyString(text)
748 isDockedDevtoolsElement
751 isIgnoreModeFocusType
758 blurActiveBrowserElement
771 checkElementOrAncestor
783 selectAllSubstringMatches
791 getAllNonOverlappingRangeOffsets