blob: 5c935903331ea0879140efea94eead490c3b4576 [file] [log] [blame]
/*
* Copyright 2011 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.google.gwt.core.client;
/*
* Design note: This class intentionally does not use the GWT DOM wrappers so
* that this code can pull in as few dependencies as possible and live in the
* Core module.
*/
/**
* Dynamically create a script tag and attach it to the DOM.
*
* Usage with script as local string:
* <p>
*
* <pre>
* String scriptBody = "var foo = ...";
* ScriptInjector.fromString(scriptBody).inject();
* </pre>
* <p>
* Usage with script loaded as URL:
* <p>
*
* <pre>
* ScriptInjector.fromUrl("http://example.com/foo.js").setCallback(
* new Callback<Void, Exception>() {
* public void onFailure(Exception reason) {
* Window.alert("Script load failed.");
* }
* public void onSuccess(Void result) {
* Window.alert("Script load success.");
* }
* }).inject();
* </pre>
*
*
*/
public class ScriptInjector {
/**
* Builder for directly injecting a script body into the DOM.
*/
public static class FromString {
private boolean removeTag = true;
private final String scriptBody;
private JavaScriptObject window;
/**
* @param scriptBody The script text to install into the document.
*/
public FromString(String scriptBody) {
this.scriptBody = scriptBody;
}
/**
* Injects a script into the DOM. The JavaScript is evaluated and will be
* available immediately when this call returns.
*
* By default, the script is installed in the same window that the GWT code
* is installed in.
*
* @return the script element created for the injection. Note that it may be
* removed from the DOM.
*/
public JavaScriptObject inject() {
JavaScriptObject wnd = (window == null) ? nativeDefaultWindow() : window;
assert wnd != null;
JavaScriptObject doc = nativeGetDocument(wnd);
assert doc != null;
JavaScriptObject scriptElement = nativeMakeScriptElement(doc);
assert scriptElement != null;
nativeSetText(scriptElement, scriptBody);
nativePropagateScriptNonceIfPossible(doc, scriptElement);
nativeAttachToHead(doc, scriptElement);
if (removeTag) {
nativeRemove(scriptElement);
}
return scriptElement;
}
/**
* @param removeTag If true, remove the tag immediately after injecting the
* source. This shrinks the DOM, possibly at the expense of
* readability if you are debugging javaScript.
*
* Default value is {@code true}.
*/
public FromString setRemoveTag(boolean removeTag) {
this.removeTag = removeTag;
return this;
}
/**
* @param window Specify which window to use to install the script. If not
* specified, the top current window GWT is loaded in is used.
*/
public FromString setWindow(JavaScriptObject window) {
this.window = window;
return this;
}
}
/**
* Build an injection call for adding a script by URL.
*/
public static class FromUrl {
private Callback<Void, Exception> callback;
private boolean removeTag = false;
private final String scriptUrl;
private JavaScriptObject window;
private FromUrl(String scriptUrl) {
this.scriptUrl = scriptUrl;
}
/**
* Injects an external JavaScript reference into the document and optionally
* calls a callback when it finishes loading.
*
* @return the script element created for the injection.
*/
public JavaScriptObject inject() {
JavaScriptObject wnd = (window == null) ? nativeDefaultWindow() : window;
assert wnd != null;
JavaScriptObject doc = nativeGetDocument(wnd);
assert doc != null;
JavaScriptObject scriptElement = nativeMakeScriptElement(doc);
assert scriptElement != null;
if (callback != null || removeTag) {
attachListeners(scriptElement, callback, removeTag);
}
nativeSetSrc(scriptElement, scriptUrl);
nativePropagateScriptNonceIfPossible(doc, scriptElement);
nativeAttachToHead(doc, scriptElement);
return scriptElement;
}
/**
* Specify a callback to be invoked when the script is loaded or loading
* encounters an error.
* <p>
* <b>Warning:</b> This class <b>does not</b> control whether or not a URL
* has already been injected into the document. The client of this class has
* the responsibility of keeping score of the injected JavaScript files.
* <p>
* <b>Known bugs:</b> This class uses the script tag's <code>onerror()
* </code> callback to attempt to invoke onFailure() if the
* browser detects a load failure. This is not reliable on all browsers
* (Doesn't work on IE or Safari 3 or less).
* <p>
* On Safari version 3 and prior, the onSuccess() callback may be invoked
* even when the load of a page fails.
* <p>
* To support failure notification on IE and older browsers, you should
* check some side effect of the script (such as a defined function)
* to see if loading the script worked and include timeout logic.
*
* @param callback callback that gets invoked asynchronously.
*/
public FromUrl setCallback(Callback<Void, Exception> callback) {
this.callback = callback;
return this;
}
/**
* @param removeTag If true, remove the tag after the script finishes
* loading. This shrinks the DOM, possibly at the expense of
* readability if you are debugging javaScript.
*
* Default value is {@code false}, but this may change in a future
* release.
*/
public FromUrl setRemoveTag(boolean removeTag) {
this.removeTag = removeTag;
return this;
}
/**
* This call allows you to specify which DOM window object to install the
* script tag in. To install into the Top level window call
*
* <code>
* builder.setWindow(ScriptInjector.TOP_WINDOW);
* </code>
*
* @param window Specifies which window to install in.
*/
public FromUrl setWindow(JavaScriptObject window) {
this.window = window;
return this;
}
}
/**
* Returns the top level window object. Use this to inject a script so that
* global variable references are available under <code>$wnd</code> in JSNI
* access.
* <p>
* Note that if your GWT app is loaded from a different domain than the top
* window, you may not be able to add a script element to the top window.
*/
public static final JavaScriptObject TOP_WINDOW = nativeTopWindow();
/**
* Build an injection call for directly setting the script text in the DOM.
*
* @param scriptBody the script text to be injected and immediately executed.
*/
public static FromString fromString(String scriptBody) {
return new FromString(scriptBody);
}
/**
* Build an injection call for adding a script by URL.
*
* @param scriptUrl URL of the JavaScript to be injected.
*/
public static FromUrl fromUrl(String scriptUrl) {
return new FromUrl(scriptUrl);
}
/**
* Attaches event handlers to a script DOM element that will run just once a
* callback when it gets successfully loaded.
* <p>
* <b>IE Notes:</b> Internet Explorer calls {@code onreadystatechanged}
* several times while varying the {@code readyState} property: in theory,
* {@code "complete"} means the content is loaded, parsed and ready to be
* used, but in practice, {@code "complete"} happens when the JS file was
* already cached, and {@code "loaded"} happens when it was transferred over
* the network. Other browsers just call the {@code onload} event handler. To
* ensure the callback will be called at most once, we clear out both event
* handlers when the callback runs for the first time. More info at the <a
* href="http://www.phpied.com/javascript-include-ready-onload/">phpied.com
* blog</a>.
* <p>
* In IE, do not trust the "order" of {@code readyState} values. For instance,
* in IE 8 running in Vista, if the JS file is cached, only {@code "complete"}
* will happen, but if the file has to be downloaded, {@code "loaded"} can
* fire in parallel with {@code "loading"}.
*
*
* @param scriptElement element to which the event handlers will be attached
* @param callback callback that runs when the script is loaded and parsed.
*/
private static native void attachListeners(JavaScriptObject scriptElement,
Callback<Void, Exception> callback, boolean removeTag) /*-{
function clearCallbacks() {
scriptElement.onerror = scriptElement.onreadystatechange = scriptElement.onload = null;
if (removeTag) {
@com.google.gwt.core.client.ScriptInjector::nativeRemove(Lcom/google/gwt/core/client/JavaScriptObject;)(scriptElement);
}
}
scriptElement.onload = $entry(function() {
clearCallbacks();
if (callback) {
callback.@com.google.gwt.core.client.Callback::onSuccess(Ljava/lang/Object;)(null);
}
});
// or possibly more portable script_tag.addEventListener('error', function(){...}, true);
scriptElement.onerror = $entry(function() {
clearCallbacks();
if (callback) {
var ex = @com.google.gwt.core.client.CodeDownloadException::new(Ljava/lang/String;)("onerror() called.");
callback.@com.google.gwt.core.client.Callback::onFailure(Ljava/lang/Object;)(ex);
}
});
scriptElement.onreadystatechange = $entry(function() {
if (/loaded|complete/.test(scriptElement.readyState)) {
scriptElement.onload();
}
});
}-*/;
private static native void nativeAttachToHead(JavaScriptObject doc, JavaScriptObject scriptElement) /*-{
// IE8 does not have document.head
(doc.head || doc.getElementsByTagName("head")[0]).appendChild(scriptElement);
}-*/;
private static native JavaScriptObject nativeDefaultWindow() /*-{
return window;
}-*/;
private static native JavaScriptObject nativeGetDocument(JavaScriptObject wnd) /*-{
return wnd.document;
}-*/;
private static native JavaScriptObject nativeMakeScriptElement(JavaScriptObject doc) /*-{
return doc.createElement("script");
}-*/;
private static native void nativeRemove(JavaScriptObject scriptElement) /*-{
scriptElement.parentNode.removeChild(scriptElement);
}-*/;
private static native void nativeSetSrc(JavaScriptObject element, String url) /*-{
element.src = url;
}-*/;
private static native void nativeSetText(JavaScriptObject element, String scriptBody) /*-{
element.text = scriptBody;
}-*/;
private static native void nativePropagateScriptNonceIfPossible(
JavaScriptObject doc, JavaScriptObject element) /*-{
if (doc.querySelector && doc.querySelector('script[nonce]')) {
var firstNoncedScript = doc.querySelector('script[nonce]');
var nonce = firstNoncedScript['nonce'] || firstNoncedScript.getAttribute('nonce');
element.setAttribute('nonce', nonce);
}
}-*/;
private static native JavaScriptObject nativeTopWindow() /*-{
return $wnd;
}-*/;
/**
* Utility class - do not instantiate
*/
private ScriptInjector() {
}
}