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