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