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