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 # Note: this is possibly a bit overzealous, but Works For Now™.
102 isDockedDevtoolsElement = (element) ->
103 return element.ownerDocument.URL.startsWith('chrome://devtools/content/')
105 isFocusable = (element) ->
106 # Focusable elements have `.tabIndex > 1` (but not necessarily a
107 # `tabindex="…"` attribute) …
108 return (element.tabIndex > -1 or
109 # … or an explicit `tabindex="-1"` attribute (which means that it is
110 # focusable, but not reachable with `<tab>`).
111 element.getAttribute?('tabindex') == '-1') and
112 not (element.localName?.endsWith?('box') and
113 element.localName != 'checkbox') and
114 not (element.localName == 'toolbarbutton' and
115 element.parentNode?.localName == 'toolbarbutton') and
116 element.localName not in ['tabs', 'menuitem', 'menuseparator']
118 isIframeEditor = (element) ->
119 return false unless element.localName == 'body'
122 element.id == 'innerdocbody' or
124 (element.classList?.contains('xe_content') and
125 element.classList?.contains('editable')) or
127 element.classList?.contains('wysiwyg') or
129 element.classList?.contains('htmlarea-content-body') or
130 # The wasavi extension.
131 element.hasAttribute?('data-wasavi-state')
133 isIgnoreModeFocusType = (element) ->
135 # The wasavi extension.
136 element.hasAttribute?('data-wasavi-state') or
137 element.closest?('#wasavi_container') or
138 # CodeMirror in Vim mode.
139 (element.localName == 'textarea' and
140 element.closest?('.CodeMirror') and _hasVimEventListener(element))
142 # CodeMirror’s Vim mode is really sucky to detect. The only way seems to be to
143 # check if the there are any event listener functions with Vim-y words in them.
144 _hasVimEventListener = (element) ->
145 for listener in nsIEventListenerService.getListenerInfoFor(element)
146 if listener.listenerObject and
147 /\bvim\b|insertmode/i.test(String(listener.listenerObject))
151 isProperLink = (element) ->
152 # `.getAttribute` is used below instead of `.hasAttribute` to exclude `<a
153 # href="">`s used as buttons on some sites.
154 return element.getAttribute?('href') and
155 (element.localName == 'a' or
156 (isXULElement(element) and
157 element.localName == 'label' and
158 element.getAttribute('is') == 'text-link')) and
159 not element.href?.endsWith?('#') and
160 not element.href?.endsWith?('#?') and
161 not element.href?.startsWith?('javascript:')
163 isTextInputElement = (element) ->
164 return (element.localName == 'input' and element.type in [
165 'text', 'search', 'tel', 'url', 'email', 'password', 'number'
167 element.localName in [ 'textarea', 'textbox' ] or
168 isContentEditable(element)
170 isTypingElement = (element) ->
171 return isTextInputElement(element) or
172 # `<select>` elements can also receive text input: You may type the
173 # text of an item to select it.
174 element.localName == 'select' or
175 element instanceof XULMenuListElement
179 # Active/focused element helpers
181 blurActiveBrowserElement = (vim) ->
182 # - Blurring in the next tick allows to pass `<escape>` to the location bar to
183 # reset it, for example.
184 # - Focusing the current browser afterwards allows to pass `<escape>` as well
185 # as unbound keys to the page. However, focusing the browser also triggers
186 # focus events on `document` and `window` in the current page. Many pages
187 # re-focus some text input on those events, making it impossible to blur
188 # those! Therefore we tell the frame script to suppress those events.
190 activeElement = getActiveElement(window)
191 activeElement.closest('tabmodalprompt')?.abortPrompt()
192 vim._send('browserRefocus')
195 window.gBrowser.selectedBrowser.focus()
198 blurActiveElement = (window) ->
199 # Blurring a frame element also blurs any active elements inside it. Recursing
200 # into the frames and blurring the “real” active element directly would give
201 # focus to the `<body>` of its containing frame, while blurring the top-most
202 # frame gives focus to the top-most `<body>`. This allows to blur fancy text
203 # editors which use an `<iframe>` as their text area.
204 # Note that this trick does not work with Web Components; for them, recursing
206 if window.document.activeElement?.shadowRoot?
207 return getActiveElement(window)?.blur()
208 window.document.activeElement?.blur()
210 # Focus an element and tell Firefox that the focus happened because of a user
211 # action (not just because some random programmatic focus). `.FLAG_BYKEY` might
212 # look more appropriate, but it unconditionally selects all text, which
213 # `.FLAG_BYMOUSE` does not.
214 focusElement = (element, options = {}) ->
215 nsIFocusManager.setFocus(element, options.flag ? 'FLAG_BYMOUSE')
216 element.select?() if options.select
218 # NOTE: In frame scripts, `document.activeElement` may be `null` when the page
219 # is loading. Therefore always check if anything was returned, such as:
221 # return unless activeElement = utils.getActiveElement(window)
222 getActiveElement = (window) ->
223 {activeElement} = window.shadowRoot or window.document
224 return null unless activeElement
225 # If the active element is a frame, recurse into it. The easiest way to detect
226 # a frame that works both in browser UI and in web page content is to check
227 # for the presence of `.contentWindow`. However, in non-multi-process,
228 # `<browser>` (sometimes `<xul:browser>`) elements have a `.contentWindow`
229 # pointing to the web page content `window`, which we don’t want to recurse
230 # into. The problem is that there are _some_ `<browser>`s which we _want_ to
231 # recurse into, such as the sidebar (for instance the history sidebar), and
232 # dialogs in `about:preferences`. Checking the `contextmenu` attribute seems
233 # to be a reliable test, catching both the main tab `<browser>`s and bookmarks
234 # opened in the sidebar.
235 # We also want to recurse into the (open) shadow DOM of custom elements.
236 if activeElement.shadowRoot?
237 return getActiveElement(activeElement)
238 else if activeElement.contentWindow and
239 not (activeElement.localName == 'browser' and
240 activeElement.getAttribute?('contextmenu') == 'contentAreaContextMenu')
241 # with Fission enabled, the iframe might be located in a different process
242 # (oop). Then, recursing into it isn't possible (throws SecurityError).
243 return activeElement unless (try activeElement.contentWindow.document)
245 return getActiveElement(activeElement.contentWindow)
249 getFocusType = (element) -> switch
250 when element.tagName in ['FRAME', 'IFRAME'] and
251 not (try element.contentWindow.document)
252 # Encountered an out-of-process iframe, which we can't inspect. We fall
253 # back to insert mode, so any text inputs it may contain are still usable.
255 when isIgnoreModeFocusType(element)
257 when isTypingElement(element)
258 if element.closest?('findbar') then 'findbar' else 'editable'
259 when isActivatable(element)
261 when isAdjustable(element)
270 listen = (element, eventName, listener, useCapture = true) ->
271 element.addEventListener(eventName, listener, useCapture)
273 element.removeEventListener(eventName, listener, useCapture)
276 listenOnce = (element, eventName, listener, useCapture = true) ->
279 element.removeEventListener(eventName, fn, useCapture)
280 listen(element, eventName, fn, useCapture)
282 onRemoved = (element, fn) ->
283 window = element.ownerGlobal
287 return if disconnected
289 mutationObserver.disconnect() unless Cu.isDeadWrapper(mutationObserver)
291 mutationObserver = new window.MutationObserver((changes) ->
292 for change in changes then for removedElement in change.removedNodes
293 if removedElement.contains?(element)
298 mutationObserver.observe(window.document.documentElement, {
302 module.onShutdown(disconnect)
306 simulateMouseEvents = (element, sequence, browserOffset) ->
307 window = element.ownerGlobal
308 rect = element.getBoundingClientRect()
309 topOffset = getTopOffset(element)
311 eventSequence = switch sequence
325 for type in eventSequence
327 when type in EVENTS_CONTEXT
329 when type in EVENTS_CLICK
334 mouseEvent = new window.MouseEvent(type, {
335 # Let the event bubble in order to trigger delegated event listeners.
336 bubbles: type not in ['mouseenter', 'mouseleave']
337 # Make the event cancelable so that `<a href="#">` can be used as a
338 # JavaScript-powered button without scrolling to the top of the page.
339 cancelable: type not in ['mouseenter', 'mouseleave']
340 # These properties are just here for mimicing a real click as much as
345 # `page{X,Y}` are set automatically to the correct values when setting
346 # `client{X,Y}`. `{offset,layer,movement}{X,Y}` are not worth the trouble
349 clientY: rect.top + rect.height / 2
350 screenX: browserOffset.x + topOffset.x
351 screenY: browserOffset.y + topOffset.y + rect.height / 2
354 if type == 'mousemove'
355 # If the below technique is used for this event, the “URL popup” (shown
356 # when hovering or focusing links) does not appear.
357 element.dispatchEvent(mouseEvent)
358 else if isInShadowRoot(element)
359 # click events for links and other clickables inside the shadow DOM are
360 # caught by the callee (.click_marker_element()).
361 element.focus() if type == 'contextmenu' # for <input type=text>
362 element.dispatchEvent(mouseEvent)
365 (window.windowUtils.dispatchDOMEventViaPresShellForTesting or
366 window.windowUtils.dispatchDOMEventViaPresShell # < fx73
367 )(element, mouseEvent)
369 if error.result != Cr.NS_ERROR_UNEXPECTED
374 suppressEvent = (event) ->
375 event.preventDefault()
376 event.stopPropagation()
383 return element.clientWidth * element.clientHeight
385 checkElementOrAncestor = (element, fn) ->
386 window = element.ownerGlobal
387 while element.parentElement
388 return true if fn(element)
389 element = element.parentElement
392 clearSelectionDeep = (window, {blur = true} = {}) ->
393 # The selection might be `null` in hidden frames.
394 selection = window.getSelection()
395 selection?.removeAllRanges()
396 # Note: accessing frameElement fails on oop iframes (fission); skip those.
397 for frame in window.frames when (try frame.frameElement)
398 clearSelectionDeep(frame, {blur})
399 # Allow parents to re-gain control of text selection.
400 frame.frameElement.blur() if blur
403 containsDeep = (parent, element) ->
404 parentWindow = parent.ownerGlobal
405 elementWindow = element.ownerGlobal
407 # Owner windows might be missing when opening the devtools.
408 while elementWindow and parentWindow and
409 elementWindow != parentWindow and elementWindow.top != elementWindow
410 element = elementWindow.frameElement
411 elementWindow = element.ownerGlobal
413 return parent.contains(element)
415 createBox = (document, className = '', parent = null, text = null) ->
416 box = document.createElement('box')
417 box.className = "#{className} vimfx-box"
418 box.textContent = text if text?
419 parent.appendChild(box) if parent?
422 # In quirks mode (when the page lacks a doctype), such as on Hackernews,
423 # `<body>` is considered the root element rather than `<html>`.
424 getRootElement = (document) ->
425 if document.compatMode == 'BackCompat' and document.body?
428 return document.documentElement
430 getText = (element) ->
431 text = element.textContent or element.value or element.placeholder or ''
432 return text.trim().replace(/\s+/, ' ')
434 getTopOffset = (element) ->
435 window = element.ownerGlobal
437 {left: x, top: y} = element.getBoundingClientRect()
438 while window.frameElement
439 frame = window.frameElement
440 frameRect = frame.getBoundingClientRect()
444 computedStyle = frame.ownerGlobal.getComputedStyle(frame)
447 parseFloat(computedStyle.getPropertyValue('border-left-width')) +
448 parseFloat(computedStyle.getPropertyValue('padding-left'))
450 parseFloat(computedStyle.getPropertyValue('border-top-width')) +
451 parseFloat(computedStyle.getPropertyValue('padding-top'))
453 window = window.parent
456 injectTemporaryPopup = (document, contents) ->
457 popup = document.createElement('menupopup')
458 popup.appendChild(contents)
459 document.getElementById('mainPopupSet').appendChild(popup)
460 listenOnce(popup, 'popuphidden', popup.remove.bind(popup))
463 insertText = (input, value) ->
464 {selectionStart, selectionEnd} = input
466 input.value[0...selectionStart] + value + input.value[selectionEnd..]
467 input.selectionStart = input.selectionEnd = selectionStart + value.length
469 isDetached = (element) ->
470 return not element.ownerDocument?.documentElement?.contains?(element)
472 isNonEmptyTextNode = (node) ->
473 return node.nodeType == 3 and node.data.trim() != ''
475 querySelectorAllDeep = (window, selector) ->
476 elements = Array.from(window.document.querySelectorAll(selector))
477 for frame in window.frames
478 elements.push(querySelectorAllDeep(frame, selector)...)
481 selectAllSubstringMatches = (element, substring, {caseSensitive = true} = {}) ->
482 window = element.ownerGlobal
483 selection = window.getSelection()
484 {textContent} = element
486 format = (string) -> if caseSensitive then string else string.toLowerCase()
488 getAllNonOverlappingRangeOffsets(format(textContent), format(substring))
489 offsetsLength = offsets.length
490 return if offsetsLength == 0
494 [currentOffset] = offsets
495 searchIndex = currentOffset.start
498 walkTextNodes(element, (textNode) ->
499 {length} = textNode.data
500 return false if length == 0
502 while textIndex + length > searchIndex
504 range = window.document.createRange()
505 range.setStart(start.textNode, start.offset)
506 range.setEnd(textNode, currentOffset.end - textIndex)
507 selection.addRange(range)
510 return true if offsetsIndex >= offsetsLength
511 currentOffset = offsets[offsetsIndex]
514 searchIndex = currentOffset.start
517 start = {textNode, offset: currentOffset.start - textIndex}
518 searchIndex = currentOffset.end - 1
524 selectElement = (element) ->
525 window = element.ownerGlobal
526 selection = window.getSelection()
527 range = window.document.createRange()
528 range.selectNodeContents(element)
529 selection.addRange(range)
531 setAttributes = (element, attributes) ->
532 for attribute, value of attributes
533 element.setAttribute(attribute, value)
536 walkTextNodes = (element, fn) ->
537 for node in element.childNodes then switch node.nodeType
542 stop = walkTextNodes(node, fn)
551 constructor: ({start: @value = 0, @step = 1}) ->
552 tick: -> @value += @step
558 on: (event, listener) ->
559 (@listeners[event] ?= new Set()).add(listener)
561 off: (event, listener) ->
562 @listeners[event]?.delete(listener)
564 emit: (event, data) ->
565 @listeners[event]?.forEach((listener) ->
569 # Returns `[nonMatch, adjacentMatchAfter]`, where `adjacentMatchAfter - nonMatch
570 # == 1`. `fn(n)` is supposed to return `false` for `n <= nonMatch` and `true`
571 # for `n >= adjacentMatchAfter`. Both `nonMatch` and `adjacentMatchAfter` may be
572 # `null` if they cannot be found. Otherwise they’re in the range `min <= n <=
573 # max`. `[null, null]` is returned in non-sensical cases. This function is
574 # intended to be used as a faster alternative to something like this:
576 # adjacentMatchAfter = null
577 # for n in [min..max]
579 # adjacentMatchAfter = n
581 bisect = (min, max, fn) ->
582 return [null, null] unless max - min >= 0 and min % 1 == 0 and max % 1 == 0
585 mid = min + (max - min) // 2
596 when matchMin and matchMax
598 when not matchMin and not matchMax
600 when not matchMin and matchMax
605 getAllNonOverlappingRangeOffsets = (string, substring) ->
607 return [] if length == 0
610 lastOffset = {start: -Infinity, end: -Infinity}
614 index = string.indexOf(substring, index + 1)
616 if index > lastOffset.end
617 lastOffset = {start: index, end: index + length}
618 offsets.push(lastOffset)
620 lastOffset.end = index + length
624 has = (obj, prop) -> Object::hasOwnProperty.call(obj, prop)
626 # Check if `search` exists in `string` (case insensitively). Returns `false` if
627 # `string` doesn’t exist or isn’t a string, such as `<SVG element>.className`.
628 includes = (string, search) ->
629 return false unless typeof string == 'string'
630 return string.toLowerCase().includes(search)
632 # Calls `fn` repeatedly, with at least `interval` ms between each call.
633 interval = (window, interval, fn) ->
635 currentIntervalId = null
638 currentIntervalId = window.setTimeout((-> fn(next)), interval)
641 window.clearTimeout(currentIntervalId)
645 nextTick = (window, fn) -> window.setTimeout((-> fn()) , 0)
647 overlaps = (rectA, rectB) ->
649 Math.round(rectA.right) >= Math.round(rectB.left) and
650 Math.round(rectA.left) <= Math.round(rectB.right) and
651 Math.round(rectA.bottom) >= Math.round(rectB.top) and
652 Math.round(rectA.top) <= Math.round(rectB.bottom)
654 partition = (array, fn) ->
657 for item, index in array
658 if fn(item, index, array)
661 nonMatching.push(item)
662 return [matching, nonMatching]
664 regexEscape = (s) -> s.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
666 removeDuplicateChars = (string) -> removeDuplicates(string.split('')).join('')
668 removeDuplicates = (array) -> Array.from(new Set(array))
670 sum = (numbers) -> numbers.reduce(((sum, number) -> sum + number), 0)
676 expandPath = (path) ->
677 if path.startsWith('~/') or path.startsWith('~\\')
678 return OS.Constants.Path.homeDir + path[1..]
682 getCurrentLocation = ->
683 return unless window = getCurrentWindow()
684 return new window.URL(window.gBrowser.selectedBrowser.currentURI.spec)
686 # This function might return `null` on startup.
687 getCurrentWindow = -> nsIWindowMediator.getMostRecentWindow('navigator:browser')
689 # gBrowser getFindBar() used to return the findBar directly, but in recent
690 # versions it returns a promise. This function should be removed once these old
691 # versions are no longer supported.
692 getFindBar = (gBrowser) ->
693 promiseOrFindBar = gBrowser.getFindBar()
694 if promiseOrFindBar instanceof Promise
697 Promise.resolve(promiseOrFindBar)
699 hasEventListeners = (element, type) ->
700 for listener in nsIEventListenerService.getListenerInfoFor(element)
701 if listener.listenerObject and listener.type == type
705 loadCss = (uriString) ->
706 uri = Services.io.newURI(uriString, null, null)
707 method = nsIStyleSheetService.AUTHOR_SHEET
708 unless nsIStyleSheetService.sheetRegistered(uri, method)
709 nsIStyleSheetService.loadAndRegisterSheet(uri, method)
711 nsIStyleSheetService.unregisterSheet(uri, method)
714 observe = (topic, observer) ->
715 observer = {observe: observer} if typeof observer == 'function'
716 Services.obs.addObserver(observer, topic, false)
718 Services.obs.removeObserver(observer, topic, false)
721 # Try to open a button’s dropdown menu, if any.
722 openDropdown = (element) ->
723 if isXULElement(element) and
724 element.getAttribute?('type') == 'menu' and
725 element.open == false # Only change `.open` if it is already a boolean.
728 openPopup = (popup) ->
729 window = popup.ownerGlobal
730 # Show the popup so it gets a height and width.
731 popup.openPopupAtScreen(0, 0)
732 # Center the popup inside the window.
734 window.screenX + window.outerWidth / 2 - popup.clientWidth / 2,
735 window.screenY + window.outerHeight / 2 - popup.clientHeight / 2
738 writeToClipboard = (text) -> nsIClipboardHelper.copyString(text)
749 isDockedDevtoolsElement
752 isIgnoreModeFocusType
759 blurActiveBrowserElement
772 checkElementOrAncestor
784 selectAllSubstringMatches
792 getAllNonOverlappingRangeOffsets