/* @VaadinApache2LicenseForJavaFiles@ */ package com.vaadin.terminal.gwt.client; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; import com.google.gwt.core.client.Duration; import com.google.gwt.core.client.GWT; import com.google.gwt.core.client.Scheduler; import com.google.gwt.core.client.Scheduler.RepeatingCommand; import com.google.gwt.dom.client.AnchorElement; import com.google.gwt.dom.client.Document; import com.google.gwt.dom.client.Element; import com.google.gwt.dom.client.LinkElement; import com.google.gwt.dom.client.NodeList; import com.google.gwt.dom.client.ObjectElement; import com.google.gwt.dom.client.ScriptElement; import com.google.gwt.user.client.Timer; /** * ResourceLoader lets you dynamically include external scripts and styles on * the page and lets you know when the resource has been loaded. * * You can also preload resources, allowing them to get cached by the browser * without being evaluated. This enables downloading multiple resources at once * while still controlling in which order e.g. scripts are executed. * * @author Vaadin Ltd * @version @VERSION@ * @since 7.0.0 */ public class ResourceLoader { /** * Event fired when a resource has been loaded. */ public static class ResourceLoadEvent { private ResourceLoader loader; private String resourceUrl; private final boolean preload; /** * Creates a new event. * * @param loader * the resource loader that has loaded the resource * @param resourceUrl * the url of the loaded resource * @param preload * true if the resource has only been preloaded, false if * it's fully loaded */ public ResourceLoadEvent(ResourceLoader loader, String resourceUrl, boolean preload) { this.loader = loader; this.resourceUrl = resourceUrl; this.preload = preload; } /** * Gets the resource loader that has fired this event * * @return the resource loader */ public ResourceLoader getResourceLoader() { return loader; } /** * Gets the absolute url of the loaded resource. * * @return the absolute url of the loaded resource */ public String getResourceUrl() { return resourceUrl; } /** * Returns true if the resource has been preloaded, false if it's fully * loaded * * @see ResourceLoader#preloadResource(String, ResourceLoadListener) * * @return true if the resource has been preloaded, false if it's fully * loaded */ public boolean isPreload() { return preload; } } /** * Event listener that gets notified when a resource has been loaded */ public interface ResourceLoadListener { /** * Notifies this ResourceLoadListener that a resource has been loaded. * Some browsers do not support any way of detecting load errors. In * these cases, onLoad will be called regardless of the status. * * @see ResourceLoadEvent * * @param event * a resource load event with information about the loaded * resource */ public void onLoad(ResourceLoadEvent event); /** * Notifies this ResourceLoadListener that a resource could not be * loaded, e.g. because the file could not be found or because the * server did not respond. Some browsers do not support any way of * detecting load errors. In these cases, onLoad will be called * regardless of the status. * * @see ResourceLoadEvent * * @param event * a resource load event with information about the resource * that could not be loaded. */ public void onError(ResourceLoadEvent event); } private static final ResourceLoader INSTANCE = GWT .create(ResourceLoader.class); private ApplicationConnection connection; private final Set loadedResources = new HashSet(); private final Set preloadedResources = new HashSet(); private final Map> loadListeners = new HashMap>(); private final Map> preloadListeners = new HashMap>(); private final Element head; /** * Creates a new resource loader. You should generally not create you own * resource loader, but instead use {@link ResourceLoader#get()} to get an * instance. */ protected ResourceLoader() { Document document = Document.get(); head = document.getElementsByTagName("head").getItem(0); // detect already loaded scripts and stylesheets NodeList scripts = document.getElementsByTagName("script"); for (int i = 0; i < scripts.getLength(); i++) { ScriptElement element = ScriptElement.as(scripts.getItem(i)); String src = element.getSrc(); if (src != null && src.length() != 0) { loadedResources.add(src); } } NodeList links = document.getElementsByTagName("link"); for (int i = 0; i < links.getLength(); i++) { LinkElement linkElement = LinkElement.as(links.getItem(i)); String rel = linkElement.getRel(); String href = linkElement.getHref(); if ("stylesheet".equalsIgnoreCase(rel) && href != null && href.length() != 0) { loadedResources.add(href); } } } /** * Returns the default ResourceLoader * * @return the default ResourceLoader */ public static ResourceLoader get() { return INSTANCE; } /** * Load a script and notify a listener when the script is loaded. Calling * this method when the script is currently loading or already loaded * doesn't cause the script to be loaded again, but the listener will still * be notified when appropriate. * * * @param scriptUrl * the url of the script to load * @param resourceLoadListener * the listener that will get notified when the script is loaded */ public void loadScript(final String scriptUrl, final ResourceLoadListener resourceLoadListener) { final String url = getAbsoluteUrl(scriptUrl); ResourceLoadEvent event = new ResourceLoadEvent(this, url, false); if (loadedResources.contains(url)) { if (resourceLoadListener != null) { resourceLoadListener.onLoad(event); } return; } if (preloadListeners.containsKey(url)) { // Preload going on, continue when preloaded preloadResource(url, new ResourceLoadListener() { @Override public void onLoad(ResourceLoadEvent event) { loadScript(url, resourceLoadListener); } @Override public void onError(ResourceLoadEvent event) { // Preload failed -> signal error to own listener if (resourceLoadListener != null) { resourceLoadListener.onError(event); } } }); return; } if (addListener(url, resourceLoadListener, loadListeners)) { ScriptElement scriptTag = Document.get().createScriptElement(); scriptTag.setSrc(url); scriptTag.setType("text/javascript"); addOnloadHandler(scriptTag, new ResourceLoadListener() { @Override public void onLoad(ResourceLoadEvent event) { fireLoad(event); } @Override public void onError(ResourceLoadEvent event) { fireError(event); } }, event); head.appendChild(scriptTag); } } private static String getAbsoluteUrl(String url) { AnchorElement a = Document.get().createAnchorElement(); a.setHref(url); return a.getHref(); } /** * Download a resource and notify a listener when the resource is loaded * without attempting to interpret the resource. When a resource has been * preloaded, it will be present in the browser's cache (provided the HTTP * headers allow caching), making a subsequent load operation complete * without having to wait for the resource to be downloaded again. * * Calling this method when the resource is currently loading, currently * preloading, already preloaded or already loaded doesn't cause the * resource to be preloaded again, but the listener will still be notified * when appropriate. * * @param url * the url of the resource to preload * @param resourceLoadListener * the listener that will get notified when the resource is * preloaded */ public void preloadResource(String url, ResourceLoadListener resourceLoadListener) { url = getAbsoluteUrl(url); ResourceLoadEvent event = new ResourceLoadEvent(this, url, true); if (loadedResources.contains(url) || preloadedResources.contains(url)) { // Already loaded or preloaded -> just fire listener if (resourceLoadListener != null) { resourceLoadListener.onLoad(event); } return; } if (addListener(url, resourceLoadListener, preloadListeners) && !loadListeners.containsKey(url)) { // Inject loader element if this is the first time this is preloaded // AND the resources isn't already being loaded in the normal way Element element = getPreloadElement(url); addOnloadHandler(element, new ResourceLoadListener() { @Override public void onLoad(ResourceLoadEvent event) { fireLoad(event); } @Override public void onError(ResourceLoadEvent event) { fireError(event); } }, event); // TODO Remove object when loaded (without causing spinner in FF) Document.get().getBody().appendChild(element); } } private static Element getPreloadElement(String url) { if (BrowserInfo.get().isIE()) { ScriptElement element = Document.get().createScriptElement(); element.setSrc(url); element.setType("text/cache"); return element; } else { ObjectElement element = Document.get().createObjectElement(); element.setData(url); element.setType("text/plain"); element.setHeight("0px"); element.setWidth("0px"); return element; } } private native void addOnloadHandler(Element element, ResourceLoadListener listener, ResourceLoadEvent event) /*-{ element.onload = $entry(function() { element.onload = null; element.onerror = null; element.onreadystatechange = null; listener.@com.vaadin.terminal.gwt.client.ResourceLoader.ResourceLoadListener::onLoad(Lcom/vaadin/terminal/gwt/client/ResourceLoader$ResourceLoadEvent;)(event); }); element.onerror = $entry(function() { element.onload = null; element.onerror = null; element.onreadystatechange = null; listener.@com.vaadin.terminal.gwt.client.ResourceLoader.ResourceLoadListener::onError(Lcom/vaadin/terminal/gwt/client/ResourceLoader$ResourceLoadEvent;)(event); }); element.onreadystatechange = function() { if ("loaded" === element.readyState || "complete" === element.readyState ) { element.onload(arguments[0]); } }; }-*/; /** * Load a stylesheet and notify a listener when the stylesheet is loaded. * Calling this method when the stylesheet is currently loading or already * loaded doesn't cause the stylesheet to be loaded again, but the listener * will still be notified when appropriate. * * @param stylesheetUrl * the url of the stylesheet to load * @param resourceLoadListener * the listener that will get notified when the stylesheet is * loaded */ public void loadStylesheet(final String stylesheetUrl, final ResourceLoadListener resourceLoadListener) { final String url = getAbsoluteUrl(stylesheetUrl); final ResourceLoadEvent event = new ResourceLoadEvent(this, url, false); if (loadedResources.contains(url)) { if (resourceLoadListener != null) { resourceLoadListener.onLoad(event); } return; } if (preloadListeners.containsKey(url)) { // Preload going on, continue when preloaded preloadResource(url, new ResourceLoadListener() { @Override public void onLoad(ResourceLoadEvent event) { loadStylesheet(url, resourceLoadListener); } @Override public void onError(ResourceLoadEvent event) { // Preload failed -> signal error to own listener if (resourceLoadListener != null) { resourceLoadListener.onError(event); } } }); return; } if (addListener(url, resourceLoadListener, loadListeners)) { LinkElement linkElement = Document.get().createLinkElement(); linkElement.setRel("stylesheet"); linkElement.setType("text/css"); linkElement.setHref(url); if (BrowserInfo.get().isSafari()) { // Safari doesn't fire any events for link elements // See http://www.phpied.com/when-is-a-stylesheet-really-loaded/ Scheduler.get().scheduleFixedPeriod(new RepeatingCommand() { private final Duration duration = new Duration(); @Override public boolean execute() { int styleSheetLength = getStyleSheetLength(url); if (getStyleSheetLength(url) > 0) { fireLoad(event); return false; // Stop repeating } else if (styleSheetLength == 0) { // "Loaded" empty sheet -> most likely 404 error fireError(event); return true; } else if (duration.elapsedMillis() > 60 * 1000) { fireError(event); return false; } else { return true; // Continue repeating } } }, 10); } else { addOnloadHandler(linkElement, new ResourceLoadListener() { @Override public void onLoad(ResourceLoadEvent event) { // Chrome && IE fires load for errors, must check // stylesheet data if (BrowserInfo.get().isChrome() || BrowserInfo.get().isIE()) { int styleSheetLength = getStyleSheetLength(url); // Error if there's an empty stylesheet if (styleSheetLength == 0) { fireError(event); return; } } fireLoad(event); } @Override public void onError(ResourceLoadEvent event) { fireError(event); } }, event); if (BrowserInfo.get().isOpera()) { // Opera onerror never fired, assume error if no onload in x // seconds new Timer() { @Override public void run() { if (!loadedResources.contains(url)) { fireError(event); } } }.schedule(5 * 1000); } } head.appendChild(linkElement); } } private static native int getStyleSheetLength(String url) /*-{ for(var i = 0; i < $doc.styleSheets.length; i++) { if ($doc.styleSheets[i].href === url) { var sheet = $doc.styleSheets[i]; try { var rules = sheet.cssRules if (rules === undefined) { rules = sheet.rules; } if (rules === null) { // Style sheet loaded, but can't access length because of XSS -> assume there's something there return 1; } // Return length so we can distinguish 0 (probably 404 error) from normal case. return rules.length; } catch (err) { return 1; } } } // No matching stylesheet found -> not yet loaded return -1; }-*/; private static boolean addListener(String url, ResourceLoadListener listener, Map> listenerMap) { Collection listeners = listenerMap.get(url); if (listeners == null) { listeners = new HashSet(); listeners.add(listener); listenerMap.put(url, listeners); return true; } else { listeners.add(listener); return false; } } private void fireError(ResourceLoadEvent event) { String resource = event.getResourceUrl(); Collection listeners; if (event.isPreload()) { // Also fire error for load listeners fireError(new ResourceLoadEvent(this, resource, false)); listeners = preloadListeners.remove(resource); } else { listeners = loadListeners.remove(resource); } if (listeners != null && !listeners.isEmpty()) { for (ResourceLoadListener listener : listeners) { if (listener != null) { listener.onError(event); } } } } private void fireLoad(ResourceLoadEvent event) { String resource = event.getResourceUrl(); Collection listeners; if (event.isPreload()) { preloadedResources.add(resource); listeners = preloadListeners.remove(resource); } else { if (preloadListeners.containsKey(resource)) { // Also fire preload events for potential listeners fireLoad(new ResourceLoadEvent(this, resource, true)); } preloadedResources.remove(resource); loadedResources.add(resource); listeners = loadListeners.remove(resource); } if (listeners != null && !listeners.isEmpty()) { for (ResourceLoadListener listener : listeners) { if (listener != null) { listener.onLoad(event); } } } } }