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