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