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