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