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