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