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