]> git.gir.st - VimFx.git/blob - extension/lib/utils.coffee
Improve `zv` caret position
[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 bisect = (min, max, fn) ->
418 return [null, null] unless max - min >= 0 and min % 1 == 0 and max % 1 == 0
419
420 while max - min > 1
421 mid = min + (max - min) // 2
422 match = fn(mid)
423 if match
424 max = mid
425 else
426 min = mid
427
428 matchMin = fn(min)
429 matchMax = fn(max)
430
431 return switch
432 when matchMin and matchMax
433 [null, min]
434 when not matchMin and not matchMax
435 [max, null]
436 when not matchMin and matchMax
437 [min, max]
438 else
439 [null, null]
440
441 has = (obj, prop) -> Object::hasOwnProperty.call(obj, prop)
442
443 # Check if `search` exists in `string` (case insensitively). Returns `false` if
444 # `string` doesn’t exist or isn’t a string, such as `<SVG element>.className`.
445 includes = (string, search) ->
446 return false unless typeof string == 'string'
447 return string.toLowerCase().includes(search)
448
449
450 nextTick = (window, fn) -> window.setTimeout((-> fn()) , 0)
451
452 regexEscape = (s) -> s.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
453
454 removeDuplicates = (array) -> Array.from(new Set(array))
455
456 # Remove duplicate characters from string (case insensitive).
457 removeDuplicateCharacters = (str) ->
458 return removeDuplicates( str.toLowerCase().split('') ).join('')
459
460 # Calls `fn` repeatedly, with at least `interval` ms between each call.
461 interval = (window, interval, fn) ->
462 stopped = false
463 currentIntervalId = null
464 next = ->
465 return if stopped
466 currentIntervalId = window.setTimeout((-> fn(next)), interval)
467 clearInterval = ->
468 stopped = true
469 window.clearTimeout(currentIntervalId)
470 next()
471 return clearInterval
472
473
474
475 # Misc helpers
476
477 expandPath = (path) ->
478 if path.startsWith('~/') or path.startsWith('~\\')
479 return OS.Constants.Path.homeDir + path[1..]
480 else
481 return path
482
483 formatError = (error) ->
484 stack = String(error.stack?.formattedStack ? error.stack ? '')
485 .split('\n')
486 .filter((line) -> line.includes('.xpi!'))
487 .map((line) -> ' ' + line.replace(/(?:\/<)*@.+\.xpi!/g, '@'))
488 .join('\n')
489 return "#{error}\n#{stack}"
490
491 getCurrentLocation = ->
492 window = getCurrentWindow()
493 return new window.URL(window.gBrowser.selectedBrowser.currentURI.spec)
494
495 getCurrentWindow = -> nsIWindowMediator.getMostRecentWindow('navigator:browser')
496
497 hasEventListeners = (element, type) ->
498 for listener in nsIEventListenerService.getListenerInfoFor(element)
499 if listener.listenerObject and listener.type == type
500 return true
501 return false
502
503 loadCss = (uriString) ->
504 uri = Services.io.newURI(uriString, null, null)
505 method = nsIStyleSheetService.AUTHOR_SHEET
506 unless nsIStyleSheetService.sheetRegistered(uri, method)
507 nsIStyleSheetService.loadAndRegisterSheet(uri, method)
508 module.onShutdown(->
509 nsIStyleSheetService.unregisterSheet(uri, method)
510 )
511
512 observe = (topic, observer) ->
513 observer = {observe: observer} if typeof observer == 'function'
514 Services.obs.addObserver(observer, topic, false)
515 module.onShutdown(->
516 Services.obs.removeObserver(observer, topic, false)
517 )
518
519 openPopup = (popup) ->
520 window = popup.ownerGlobal
521 # Show the popup so it gets a height and width.
522 popup.openPopupAtScreen(0, 0)
523 # Center the popup inside the window.
524 popup.moveTo(
525 window.screenX + window.outerWidth / 2 - popup.clientWidth / 2,
526 window.screenY + window.outerHeight / 2 - popup.clientHeight / 2
527 )
528
529 writeToClipboard = (text) -> nsIClipboardHelper.copyString(text)
530
531
532
533 module.exports = {
534 isActivatable
535 isAdjustable
536 isContentEditable
537 isFocusable
538 isIframeEditor
539 isIgnoreModeFocusType
540 isProperLink
541 isTextInputElement
542 isTypingElement
543
544 getActiveElement
545 blurActiveElement
546 blurActiveBrowserElement
547 focusElement
548 getFocusType
549
550 listen
551 listenOnce
552 onRemoved
553 suppressEvent
554 simulateMouseEvents
555
556 area
557 clearSelectionDeep
558 containsDeep
559 createBox
560 getRootElement
561 injectTemporaryPopup
562 insertText
563 isDetached
564 isPositionFixed
565 querySelectorAllDeep
566 setAttributes
567 setHover
568
569 Counter
570 EventEmitter
571 bisect
572 has
573 includes
574 nextTick
575 regexEscape
576 removeDuplicates
577 removeDuplicateCharacters
578 interval
579
580 expandPath
581 formatError
582 getCurrentLocation
583 getCurrentWindow
584 hasEventListeners
585 loadCss
586 observe
587 openPopup
588 writeToClipboard
589 }
Imprint / Impressum