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