]> git.gir.st - VimFx.git/blob - extension/lib/utils.coffee
Rework programmatic customization of VimFx
[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 HTMLAnchorElement = Ci.nsIDOMHTMLAnchorElement
38 HTMLButtonElement = Ci.nsIDOMHTMLButtonElement
39 HTMLInputElement = Ci.nsIDOMHTMLInputElement
40 HTMLTextAreaElement = Ci.nsIDOMHTMLTextAreaElement
41 HTMLSelectElement = Ci.nsIDOMHTMLSelectElement
42 HTMLBodyElement = Ci.nsIDOMHTMLBodyElement
43 XULDocument = Ci.nsIDOMXULDocument
44 XULButtonElement = Ci.nsIDOMXULButtonElement
45 XULControlElement = Ci.nsIDOMXULControlElement
46 XULMenuListElement = Ci.nsIDOMXULMenuListElement
47 XULTextBoxElement = Ci.nsIDOMXULTextBoxElement
48
49 # Full chains of events for different mouse actions. ('command' is for XUL
50 # elements.)
51 EVENTS_CLICK = ['mousedown', 'mouseup', 'click', 'command']
52 EVENTS_HOVER_START = ['mouseover', 'mouseenter', 'mousemove']
53 EVENTS_HOVER_END = ['mouseout', 'mouseleave']
54
55
56
57 # Element classification helpers
58
59 isActivatable = (element) ->
60 return element instanceof HTMLAnchorElement or
61 element instanceof HTMLButtonElement or
62 (element instanceof HTMLInputElement and element.type in [
63 'button', 'submit', 'reset', 'image'
64 ]) or
65 element instanceof XULButtonElement
66
67 isAdjustable = (element) ->
68 return element instanceof HTMLInputElement and element.type in [
69 'checkbox', 'radio', 'file', 'color'
70 'date', 'time', 'datetime', 'datetime-local', 'month', 'week'
71 ] or
72 element instanceof XULControlElement or
73 # Youtube special case.
74 element.classList?.contains('html5-video-player') or
75 element.classList?.contains('ytp-button')
76
77 isContentEditable = (element) ->
78 return element.isContentEditable or
79 isIframeEditor(element) or
80 # Google.
81 element.getAttribute?('g_editable') == 'true' or
82 element.ownerDocument?.body?.getAttribute('g_editable') == 'true' or
83 # Codeacademy terminals.
84 element.classList?.contains('real-terminal')
85
86 isIframeEditor = (element) ->
87 return false unless element instanceof HTMLBodyElement
88 return \
89 # Etherpad.
90 element.id == 'innerdocbody' or
91 # XpressEditor.
92 (element.classList.contains('xe_content') and
93 element.classList.contains('editable')) or
94 # vBulletin.
95 element.classList.contains('wysiwyg')
96
97 isProperLink = (element) ->
98 # `.getAttribute` is used below instead of `.hasAttribute` to exclude `<a
99 # href="">`s used as buttons on some sites.
100 return element.getAttribute('href') and
101 (element instanceof HTMLAnchorElement or
102 element.ownerDocument instanceof XULDocument) and
103 not element.href.endsWith('#') and
104 not element.href.endsWith('#?') and
105 not element.href.startsWith('javascript:')
106
107 isTextInputElement = (element) ->
108 return (element instanceof HTMLInputElement and element.type in [
109 'text', 'search', 'tel', 'url', 'email', 'password', 'number'
110 ]) or
111 element instanceof HTMLTextAreaElement or
112 element instanceof XULTextBoxElement or
113 isContentEditable(element)
114
115 isTypingElement = (element) ->
116 return isTextInputElement(element) or
117 # `<select>` elements can also receive text input: You may type the
118 # text of an item to select it.
119 element instanceof HTMLSelectElement or
120 element instanceof XULMenuListElement
121
122
123
124 # Active/focused element helpers
125
126 # NOTE: In frame scripts, `document.activeElement` may be `null` when the page
127 # is loading. Therefore always check if anything was returned, such as:
128 #
129 # return unless activeElement = utils.getActiveElement(window)
130 getActiveElement = (window) ->
131 {activeElement} = window.document
132 return null unless activeElement
133 # If the active element is a frame, recurse into it. The easiest way to detect
134 # a frame that works both in browser UI and in web page content is to check
135 # for the presence of `.contentWindow`. However, in non-multi-process
136 # `<browser>` (sometimes `<xul:browser>`) elements have a `.contentWindow`
137 # pointing to the web page content `window`, which we don’t want to recurse
138 # into. `.localName` is `.nodeName` without `xul:` (if it exists). This seems
139 # 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' then EVENTS_CLICK
224 when 'hover-start' then EVENTS_HOVER_START
225 when 'hover-end' then EVENTS_HOVER_END
226
227 for type in eventSequence
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: if type in EVENTS_CLICK then 1 else 0
237 detail: if type in EVENTS_CLICK then 1 else 0
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 injectTemporaryPopup = (document, contents) ->
281 popup = document.createElement('menupopup')
282 popup.appendChild(contents)
283 document.getElementById('mainPopupSet').appendChild(popup)
284 listenOnce(popup, 'popuphidden', popup.remove.bind(popup))
285 return popup
286
287 insertText = (input, value) ->
288 {selectionStart, selectionEnd} = input
289 input.value =
290 input.value[0...selectionStart] + value + input.value[selectionEnd..]
291 input.selectionStart = input.selectionEnd = selectionStart + value.length
292
293 querySelectorAllDeep = (window, selector) ->
294 elements = Array.from(window.document.querySelectorAll(selector))
295 for frame in window.frames
296 elements.push(querySelectorAllDeep(frame, selector)...)
297 return elements
298
299 scroll = (element, args) ->
300 {method, type, directions, amounts, properties, adjustment, smooth} = args
301 options = {}
302 for direction, index in directions
303 amount = amounts[index]
304 options[direction] = -Math.sign(amount) * adjustment + switch type
305 when 'lines' then amount
306 when 'pages' then amount * element[properties[index]]
307 when 'other' then Math.min(amount, element[properties[index]])
308 options.behavior = 'smooth' if smooth
309 element[method](options)
310
311 setAttributes = (element, attributes) ->
312 for attribute, value of attributes
313 element.setAttribute(attribute, value)
314 return
315
316 setHover = (element, hover) ->
317 method = if hover then 'addPseudoClassLock' else 'removePseudoClassLock'
318 while element.parentElement
319 nsIDomUtils[method](element, ':hover')
320 element = element.parentElement
321 return
322
323
324
325 # Language helpers
326
327 class Counter
328 constructor: ({start: @value = 0, @step = 1}) ->
329 tick: -> @value += @step
330
331 class EventEmitter
332 constructor: ->
333 @listeners = {}
334
335 on: (event, listener) ->
336 (@listeners[event] ?= new Set()).add(listener)
337
338 off: (event, listener) ->
339 @listeners[event]?.delete(listener)
340
341 emit: (event, data) ->
342 @listeners[event]?.forEach((listener) ->
343 listener(data)
344 )
345
346 has = (obj, prop) -> Object::hasOwnProperty.call(obj, prop)
347
348 # Check if `search` exists in `string` (case insensitively). Returns `false` if
349 # `string` doesn’t exist or isn’t a string, such as `<SVG element>.className`.
350 includes = (string, search) ->
351 return false unless typeof string == 'string'
352 return string.toLowerCase().includes(search)
353
354
355 nextTick = (window, fn) -> window.setTimeout((-> fn()) , 0)
356
357 regexEscape = (s) -> s.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
358
359 removeDuplicates = (array) -> Array.from(new Set(array))
360
361 # Remove duplicate characters from string (case insensitive).
362 removeDuplicateCharacters = (str) ->
363 return removeDuplicates( str.toLowerCase().split('') ).join('')
364
365 # Calls `fn` repeatedly, with at least `interval` ms between each call.
366 interval = (window, interval, fn) ->
367 stopped = false
368 currentIntervalId = null
369 next = ->
370 return if stopped
371 currentIntervalId = window.setTimeout((-> fn(next)), interval)
372 clearInterval = ->
373 stopped = true
374 window.clearTimeout(currentIntervalId)
375 next()
376 return clearInterval
377
378
379
380 # Misc helpers
381
382 formatError = (error) ->
383 stack = String(error.stack?.formattedStack ? error.stack ? '')
384 .split('\n')
385 .filter((line) -> line.includes('.xpi!'))
386 .map((line) -> ' ' + line.replace(/(?:\/<)*@.+\.xpi!/g, '@'))
387 .join('\n')
388 return "#{error}\n#{stack}"
389
390 getCurrentLocation = ->
391 window = getCurrentWindow()
392 return new window.URL(window.gBrowser.selectedBrowser.currentURI.spec)
393
394 getCurrentWindow = -> nsIWindowMediator.getMostRecentWindow('navigator:browser')
395
396 hasEventListeners = (element, type) ->
397 for listener in nsIEventListenerService.getListenerInfoFor(element)
398 if listener.listenerObject and listener.type == type
399 return true
400 return false
401
402 loadCss = (uriString) ->
403 uri = Services.io.newURI(uriString, null, null)
404 method = nsIStyleSheetService.AUTHOR_SHEET
405 unless nsIStyleSheetService.sheetRegistered(uri, method)
406 nsIStyleSheetService.loadAndRegisterSheet(uri, method)
407 module.onShutdown(->
408 nsIStyleSheetService.unregisterSheet(uri, method)
409 )
410
411 observe = (topic, observer) ->
412 observer = {observe: observer} if typeof observer == 'function'
413 Services.obs.addObserver(observer, topic, false)
414 module.onShutdown(->
415 Services.obs.removeObserver(observer, topic, false)
416 )
417
418 openPopup = (popup) ->
419 window = popup.ownerGlobal
420 # Show the popup so it gets a height and width.
421 popup.openPopupAtScreen(0, 0)
422 # Center the popup inside the window.
423 popup.moveTo(
424 window.screenX + window.outerWidth / 2 - popup.clientWidth / 2,
425 window.screenY + window.outerHeight / 2 - popup.clientHeight / 2
426 )
427
428 openTab = (window, url, options) ->
429 {gBrowser} = window
430 window.TreeStyleTabService?.readyToOpenChildTab(gBrowser.selectedTab)
431 gBrowser.loadOneTab(url, options)
432
433 writeToClipboard = (text) -> nsIClipboardHelper.copyString(text)
434
435
436
437 module.exports = {
438 isActivatable
439 isAdjustable
440 isContentEditable
441 isProperLink
442 isTextInputElement
443 isTypingElement
444
445 getActiveElement
446 blurActiveElement
447 blurActiveBrowserElement
448 focusElement
449 getFocusType
450
451 listen
452 listenOnce
453 onRemoved
454 suppressEvent
455 simulateMouseEvents
456
457 area
458 containsDeep
459 createBox
460 injectTemporaryPopup
461 insertText
462 querySelectorAllDeep
463 scroll
464 setAttributes
465 setHover
466
467 Counter
468 EventEmitter
469 has
470 includes
471 nextTick
472 regexEscape
473 removeDuplicates
474 removeDuplicateCharacters
475 interval
476
477 formatError
478 getCurrentLocation
479 getCurrentWindow
480 hasEventListeners
481 loadCss
482 observe
483 openPopup
484 openTab
485 writeToClipboard
486 }
Imprint / Impressum