/* * Copyright (c) 2014 tabletoptool.com team. * All rights reserved. This program and the accompanying materials * are made available under the terms of the GNU Public License v3.0 * which accompanies this distribution, and is available at * http://www.gnu.org/licenses/gpl.html * * Contributors: * rptools.com team - initial implementation * tabletoptool.com team - further development */ package com.t3.client; import java.awt.datatransfer.DataFlavor; import java.awt.datatransfer.Transferable; import java.awt.datatransfer.UnsupportedFlavorException; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.StringTokenizer; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.imageio.ImageIO; import javax.swing.JComponent; import javax.swing.SwingUtilities; import javax.swing.TransferHandler; import org.apache.log4j.Level; import org.apache.log4j.Logger; import com.t3.MD5Key; import com.t3.image.ImageUtil; import com.t3.language.I18N; import com.t3.model.Asset; import com.t3.model.AssetManager; import com.t3.model.Token; import com.t3.persistence.PersistenceUtil; import com.t3.transferable.FileTransferableHandler; import com.t3.transferable.GroupTokenTransferData; import com.t3.transferable.ImageTransferableHandler; import com.t3.transferable.T3TokenTransferData; import com.t3.transferable.TokenTransferData; import com.t3.util.StringUtil; /** * A helper class for converting Transferable objects into their respective data * types. This class hides the details of drag/drop protocols as much as * possible and therefore contains platform-dependent checks (such as the * URI_LIST_FLAVOR hack needed for Linux). * <p> * <b>Note:</b> Drag-n-drop operations cannot be properly debugged by setting * breakpoints at random locations. For example, once a drop operation occurs * the code must run to a point where getTransferData() has been called as the * JRE is maintaining some of the state internally and hitting a breakpoint * disturbs that state. (For instance the JRE only allows a single drag * operation at a time -- how could there be more? -- so a global structure is * used to record drag information and some of the fields are queried from the * peer component which may be time-sensitive.) * * @author tcroft */ public class TransferableHelper extends TransferHandler { private static final long serialVersionUID = 6019141249887841907L; private static final Logger log = Logger.getLogger(TransferableHelper.class); /** * <b>text/uri-list; class=java.lang.String</b> * <p> * This is a JRE bug on Linux; the JRE <i>should</i> be providing * DataFlavor.javaFileListFlavor but doesn't. :( */ private static final DataFlavor URI_LIST_FLAVOR = new DataFlavor("text/uri-list; class=java.lang.String", "Image"); //$NON-NLS-1$ /** * <b>application/x-java-url; class=java.net.URL</b> * <p> * The best type of object to get is this one -- a URL -- since the * representation of URLs is universal */ private static final DataFlavor URL_FLAVOR_URI = new DataFlavor("application/x-java-url; class=java.net.URL", "Image"); //$NON-NLS-1$ /** * <b>image/x-java-image; class=java.awt.Image</b> * <p> * The next best type of object to get is this one, since the JRE has * already recognized the type of data */ private static final DataFlavor X_JAVA_IMAGE = new DataFlavor("image/x-java-image; class=java.awt.Image", "Image"); //$NON-NLS-1$ /** * <b>text/plain; class=java.lang.String</b> * <p> * The last type of object to check for is text/plain. It's likely a URL -- * or so we assume. :( */ private static final DataFlavor URL_FLAVOR_PLAIN = new DataFlavor("text/plain; class=java.lang.String", "Image"); //$NON-NLS-1$ /** * Data flavors that this handler will support. */ // @formatter:off public static final DataFlavor[] SUPPORTED_FLAVORS = { TransferableAsset.dataFlavor, TransferableAssetReference.dataFlavor, URL_FLAVOR_URI, // Prefer the real one (although this list isn't necessarily scanned in order) X_JAVA_IMAGE, URL_FLAVOR_PLAIN, DataFlavor.javaFileListFlavor, URI_LIST_FLAVOR, TransferableToken.dataFlavor, T3TokenTransferData.MAP_TOOL_TOKEN_LIST_FLAVOR, // Is this appropriate? never used herein... GroupTokenTransferData.GROUP_TOKEN_LIST_FLAVOR, }; // @formatter:on /** * Looks at a complete URL and tries to figure out which string within the * URL might be the name of an image. * <p> * It does this by looking for known filename extensions such as JPG, JPEG, * and PNG to determine where the end of the name might be, then works left * from there looking for something not normally part of a name. For * example, in the query string of a URL it would stop looking at an equal * sign ("="), an ampersand ("&"), a question mark ("?"), or a number * sign ("#"). * * @throws URISyntaxException */ private static String findName(URL url) { String result = null; URI uri; try { // Try to use a URI, since the '%20' encoding will be automatically converted for us. uri = url.toURI(); if (!StringUtil.isEmpty(uri.getQuery())) { result = findNameInThisPiece(uri.getQuery()); } else if (!StringUtil.isEmpty(uri.getPath())) { result = findNameInThisPiece(uri.getPath()); } } catch (URISyntaxException e) { // But if we can't make a URI work, fallback to just the URL. if (!StringUtil.isEmpty(url.getQuery())) { result = findNameInThisPiece(url.getQuery()); } else if (!StringUtil.isEmpty(url.getPath())) { result = findNameInThisPiece(url.getPath()); } } // If there is a query string, start there. return result; } private static Pattern extensionPattern = null; private static String findNameInThisPiece(String text) { if (extensionPattern == null) { String extensions[] = ImageIO.getReaderFileSuffixes(); String list = Arrays.deepToString(extensions); // Final result is something like: (\w+\.(jpeg|jpg|png|gif|tiff)) String pattern = "([^/\\\\]+\\." + list.replace('[', '(').replace(']', ')').replace(", ", "|") + ")\\b"; extensionPattern = Pattern.compile(pattern, Pattern.CASE_INSENSITIVE); } Matcher m = extensionPattern.matcher(text); if (m.find()) return m.group(); return null; } /** * Takes a drop event and returns an asset from it. Returns null if an asset * could not be obtained. */ public static List<Object> getAsset(Transferable transferable) { List<Object> assets = new ArrayList<Object>(); try { Object o = null; // This *really* should be done using either the Strategy or Template patterns. Sigh. // EXISTING ASSET if (o == null && transferable.isDataFlavorSupported(TransferableAsset.dataFlavor)) { if (log.isInfoEnabled()) log.info("Selected: " + TransferableAsset.dataFlavor); o = handleTransferableAsset(transferable); } if (o == null && transferable.isDataFlavorSupported(TransferableAssetReference.dataFlavor)) { if (log.isInfoEnabled()) log.info("Selected: " + TransferableAssetReference.dataFlavor); o = handleTransferableAssetReference(transferable); } /** * Check for all InputStream types first? * <p> * This would allow an application to give us a data stream instead * of, for example, a URL. This could be significantly better for * web browsers since they have already downloaded the image anyway * and could give us an InputStream connected to the cached data. * But being passed an InputStream is a bit of a pain since the MIME * type can't be known in advance for all possible applications. * We'd need to loop through all of them * {@link #whichOnesWork(Transferable)} and look for ones that * return InputStream. But how to choose which of those to actually * use? */ // LOCAL FILESYSTEM // Used by Linux when files are dragged from the desktop. Other systems don't use this so we're safe checking for it first. // Note that "text/uri-list" is considered a JRE bug and it should be converting the event into "text/x-java-file-list", but // until it does... if (o == null && transferable.isDataFlavorSupported(URI_LIST_FLAVOR)) { if (log.isInfoEnabled()) log.info("Selected: " + URI_LIST_FLAVOR); String data = (String) transferable.getTransferData(URI_LIST_FLAVOR); List<URL> list = textURIListToFileList(data); o = handleURLList(list); } // LOCAL FILESYSTEM // Used by OSX (and Windows?) when files are dragged from the desktop: 'text/java-file-list; java.util.List<java.io.File>' if (o == null && transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { if (log.isInfoEnabled()) log.info("Selected: " + DataFlavor.javaFileListFlavor); List<File> list = new FileTransferableHandler().getTransferObject(transferable); o = handleFileList(list); } // DIRECT/BROWSER // Try 'image/x-java-image; java.awt.Image' to see if Java has recognized the image as such if (o == null && transferable.isDataFlavorSupported(X_JAVA_IMAGE)) { if (log.isInfoEnabled()) log.info("Selected: " + X_JAVA_IMAGE); BufferedImage image = (BufferedImage) new ImageTransferableHandler().getTransferObject(transferable); o = new Asset("unnamed", ImageUtil.imageToBytes(image)); } // DIRECT/BROWSER // Try 'application/x-java-url; java.net.URL' if (o == null && transferable.isDataFlavorSupported(URL_FLAVOR_URI)) { if (log.isInfoEnabled()) log.info("Selected: " + URL_FLAVOR_URI); URL url = (URL) transferable.getTransferData(URL_FLAVOR_URI); o = handleImage(url, "URL_FLAVOR_URI", transferable); } // DIRECT/BROWSER // It may be that the dropped object is a URL but is 'text/plain; java.lang.String' and URLs are better than other file types... if (o == null && transferable.isDataFlavorSupported(URL_FLAVOR_PLAIN)) { if (log.isInfoEnabled()) log.info("Selected: " + URL_FLAVOR_PLAIN); String text = (String) transferable.getTransferData(URL_FLAVOR_PLAIN); URL url = new URL(text); o = handleImage(url, "URL_FLAVOR_PLAIN", transferable); } if (o != null) { if (o instanceof List) assets = (List<Object>) o; else assets.add(o); } } catch (Exception e) { TabletopTool.showError("TransferableHelper.error.unrecognizedAsset", e); //$NON-NLS-1$ return null; } if (assets == null || assets.isEmpty()) { return null; } for (Object working : assets) { if (working instanceof Asset) { Asset asset = (Asset) working; if (!AssetManager.hasAsset(asset)) AssetManager.putAsset(asset); if (!TabletopTool.getCampaign().containsAsset(asset)) TabletopTool.serverCommand().putAsset(asset); } } return assets; } private static List<URL> textURIListToFileList(String data) { List<URL> list = new ArrayList<URL>(4); for (StringTokenizer st = new StringTokenizer(data, "\r\n"); st.hasMoreTokens();) { //$NON-NLS-1$ String s = st.nextToken(); if (s.startsWith("#")) { //$NON-NLS-1$ // the line is a comment (as per RFC 2483) continue; } try { URI uri = new URI(s); URL url = uri.toURL(); list.add(url); } catch (Exception e) { // There's no reason to trap the individual exceptions when a single catch suffices. if (log.isInfoEnabled()) log.info(s, e); // } catch (URISyntaxException e) { // Thrown by the URI constructor // e.printStackTrace(); // } catch (IllegalArgumentException e) { // Thrown by URI.toURL() // e.printStackTrace(); // } catch (MalformedURLException e) { // Thrown by URI.toURL() // e.printStackTrace(); } } return list; } private static Asset handleImage(URL url, String type, Transferable transferable) throws IOException, UnsupportedFlavorException { BufferedImage image = null; Asset asset = null; try { if (log.isDebugEnabled()) log.debug("Reading URL: " + url); //$NON-NLS-1$ image = ImageIO.read(url); } catch (Exception e) { TabletopTool.showError("TransferableHelper.error.urlFlavor", e); //$NON-NLS-1$ } if (image == null) { if (log.isDebugEnabled()) log.debug(type + " didn't work; trying ImageTransferableHandler().getTransferObject()"); //$NON-NLS-1$ image = (BufferedImage) new ImageTransferableHandler().getTransferObject(transferable); } if (image != null) { String name = findName(url); asset = new Asset(name != null ? name : "unnamed", ImageUtil.imageToBytes(image)); } else { throw new IllegalArgumentException("cannot convert drop object to image: " + url.toString()); } return asset; } // private static Asset handleImage(Transferable transferable) throws IOException, UnsupportedFlavorException { // String name = null; // BufferedImage image = null; // if (transferable.isDataFlavorSupported(URL_FLAVOR_PLAIN)) { // try { // String fname = (String) transferable.getTransferData(URL_FLAVOR_PLAIN); // if (log.isDebugEnabled()) // log.debug("Transferable " + fname); //$NON-NLS-1$ // name = FileUtil.getNameWithoutExtension(fname); // // File file; // URL url = new URL(fname); // try { // URI uri = url.toURI(); // Should replace '%20' sequences and such // file = new File(uri); // } catch (URISyntaxException e) { // file = new File(fname); // } // if (file.exists()) { // if (log.isDebugEnabled()) // log.debug("Reading local file: " + file); //$NON-NLS-1$ // image = ImageIO.read(file); // } else { // if (log.isDebugEnabled()) // log.debug("Reading remote URL: " + url); //$NON-NLS-1$ // image = ImageIO.read(url); // } // } catch (Exception e) { // TabletopTool.showError("TransferableHelper.error.urlFlavor", e); //$NON-NLS-1$ // } // } // if (image == null) { // if (log.isDebugEnabled()) // log.debug("URL_FLAVOR_PLAIN didn't work; trying ImageTransferableHandler().getTransferObject()"); //$NON-NLS-1$ // image = (BufferedImage) new ImageTransferableHandler().getTransferObject(transferable); // } // Asset asset = new Asset(name, ImageUtil.imageToBytes(image)); // return asset; // } private static List<Object> handleURLList(List<URL> list) throws Exception { List<Object> assets = new ArrayList<Object>(); for (URL url : list) { // A JFileChooser (at least under Linux) sends a couple empty filenames that need to be ignored. if (!url.getPath().equals("")) { //$NON-NLS-1$ if (Token.isTokenFile(url.getPath())) { // Loading the token causes the assets to be added to the AssetManager // so it doesn't need to be added to our List here. In fact, getAsset() // will strip out anything in the List that isn't an Asset anyway... Token token = PersistenceUtil.loadToken(url); assets.add(token); } else { Asset temp = AssetManager.createAsset(url); if (temp != null) // `null' means no image available assets.add(temp); else if (log.isInfoEnabled()) log.info("No image available for " + url); } } } return assets; } // Method Added for handling FileLists private static List<Object> handleFileList(List<File> list) throws Exception { List<Object> assets = new ArrayList<Object>(); for (File file : list) { // A JFileChooser (at least under Linux) sends a couple empty filenames that need to be ignored. if (!file.getPath().equals("")) { //$NON-NLS-1$ if (Token.isTokenFile(file.getPath())) { // Loading the token causes the assets to be added to the AssetManager // so it doesn't need to be added to our List here. In fact, getAsset() // will strip out anything in the List that isn't an Asset anyway... Token token = PersistenceUtil.loadToken(file); assets.add(token); } else { Asset temp = AssetManager.createAsset(file); if (temp != null) // `null' means no image available assets.add(temp); else if (log.isInfoEnabled()) log.info("No image available for " + file); } } } return assets; } private static Asset handleTransferableAssetReference(Transferable transferable) throws Exception { return AssetManager.getAsset((MD5Key) transferable.getTransferData(TransferableAssetReference.dataFlavor)); } private static Asset handleTransferableAsset(Transferable transferable) throws Exception { return (Asset) transferable.getTransferData(TransferableAsset.dataFlavor); } /** * Get the tokens from a token list data flavor. * * @param transferable * The data that was dropped. * @return The tokens from the data or <code>null</code> if this isn't the * proper data type. */ @SuppressWarnings("unchecked") public static List<Token> getTokens(Transferable transferable) { List<Token> tokens = null; try { Object df = transferable.getTransferData(GroupTokenTransferData.GROUP_TOKEN_LIST_FLAVOR); List<TokenTransferData> tokenMaps = (List<TokenTransferData>) df; tokens = new ArrayList<Token>(); for (Object object : tokenMaps) { if (!(object instanceof TokenTransferData)) continue; TokenTransferData td = (TokenTransferData) object; if (td.getName() == null || td.getName().trim().length() == 0 || td.getToken() == null) continue; tokens.add(new Token(td)); } // endfor if (tokens.size() != tokenMaps.size()) { final int missingTokens = tokenMaps.size() - tokens.size(); final String message = I18N.getText("TransferableHelper.warning.tokensAddedAndExcluded", tokens.size(), missingTokens); //$NON-NLS-1$ // if (EventQueue.isDispatchThread()) // System.out.println("Yes, we are on the EDT already."); SwingUtilities.invokeLater(new Runnable() { @Override public void run() { TabletopTool.showWarning(message); } }); } // endif } catch (IOException e) { TabletopTool.showError("TransferableHelper.error.ioException", e); //$NON-NLS-1$ } catch (UnsupportedFlavorException e) { TabletopTool.showError("TransferableHelper.error.unsupportedFlavorException", e); //$NON-NLS-1$ } return tokens; } public static boolean isSupportedAssetFlavor(Transferable transferable) { return transferable.isDataFlavorSupported(TransferableAsset.dataFlavor) || transferable.isDataFlavorSupported(TransferableAssetReference.dataFlavor) || transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor) || transferable.isDataFlavorSupported(URI_LIST_FLAVOR) || transferable.isDataFlavorSupported(URL_FLAVOR_PLAIN); } public static boolean isSupportedTokenFlavor(Transferable transferable) { return transferable.isDataFlavorSupported(GroupTokenTransferData.GROUP_TOKEN_LIST_FLAVOR) || transferable.isDataFlavorSupported(TransferableToken.dataFlavor); } /** * @see javax.swing.TransferHandler#canImport(javax.swing.JComponent, * java.awt.datatransfer.DataFlavor[]) */ @Override public boolean canImport(JComponent comp, DataFlavor[] transferFlavors) { for (int j = 0; j < SUPPORTED_FLAVORS.length; j++) { for (int i = 0; i < transferFlavors.length; i++) { if (SUPPORTED_FLAVORS[j].equals(transferFlavors[i])) return true; } } return false; } /** The tokens to be loaded onto the renderer when we get a point */ List<Token> tokens; /** * Whether or not each token needs additional configuration (set footprint, * guess shape). */ List<Boolean> configureTokens; /** * Retrieves a list of DataFlavors from the passed in Transferable, then * tries to actually retrieve an object from the drop event using each one. * <p> * Theoretically at least one should always work. But this can backfire as * some data sources may not support retreiving the object more than once. * (Think of a data source such as unidirectional pipe.) * * @param t * Transferable to check * @return a list of all DataFlavor objects that succeeded */ private static List<DataFlavor> whichOnesWork(Transferable t) { List<DataFlavor> worked = new ArrayList<DataFlavor>(); // On OSX Java6, any data flavor that uses java.nio.ByteBuffer or an array of bytes // appears to output the object to the console (via System.out?). Geez, can't // Apple even run a frakkin' grep against their code before releasing it?! // PrintStream old = null; // if (TabletopTool.MAC_OS_X) { // old = System.out; // setOnOff(null); // } for (DataFlavor flavor : t.getTransferDataFlavors()) { Object result = null; try { result = t.getTransferData(flavor); } catch (UnsupportedFlavorException ufe) { if (log.isDebugEnabled()) log.debug("Failed (UFE): " + flavor.toString()); //$NON-NLS-1$ } catch (IOException ioe) { if (log.isDebugEnabled()) log.debug("Failed (IOE): " + flavor.toString()); //$NON-NLS-1$ } catch (Exception e) { // System.err.println(e); } if (result != null) { for (Class<?> type : validTypes) { if (type.equals(result.getClass())) { worked.add(flavor); if (log.isInfoEnabled()) log.info("Possible: " + flavor.toString() + " (" + result + ")"); //$NON-NLS-1$ break; } } } } // if (TabletopTool.MAC_OS_X) // setOnOff(old); return worked; } // private static void setOnOff(PrintStream old) { // System.setOut(old); // } private static final Class<?> validTypes[] = { java.lang.String.class, java.net.URL.class, java.util.List.class, java.awt.Image.class, }; /** * @see javax.swing.TransferHandler#importData(javax.swing.JComponent, * java.awt.datatransfer.Transferable) */ @Override public boolean importData(JComponent comp, Transferable t) { if (tokens != null) { //tokens.clear(); // will not help with memory cleanup and we may see unmodifiable lists here tokens = null; } if (configureTokens != null) { //configureTokens.clear(); // will not help with memory cleanup and we may see unmodifiable lists here configureTokens = null; } if (log.isInfoEnabled()) whichOnesWork(t); List<Object> assets = getAsset(t); if (assets != null) { tokens = new ArrayList<Token>(assets.size()); configureTokens = new ArrayList<Boolean>(assets.size()); // Zone zone = TabletopTool.getFrame().getCurrentZoneRenderer().getZone(); for (Object working : assets) { if (working instanceof Asset) { Asset asset = (Asset) working; Token token = new Token(asset.getName(), asset.getId()); // token.setName(T3Util.nextTokenId(zone, token)); tokens.add(token); // A token from an image asset needs additional configuration. configureTokens.add(true); } else if (working instanceof Token) { Token token = new Token((Token) working); // token.setName(T3Util.nextTokenId(zone, token)); tokens.add(token); // A token from an .rptok file is already fully configured. configureTokens.add(false); } } } else { if (t.isDataFlavorSupported(TransferableToken.dataFlavor)) { try { // Make a copy so that it gets a new unique GUID tokens = Collections.singletonList(new Token((Token) t.getTransferData(TransferableToken.dataFlavor))); // A token from the Resource Library is already fully configured. configureTokens = Collections.singletonList(new Boolean(false)); } catch (Exception e) { // There's no reason to trap the individual exceptions when a single catch suffices. if (log.isEnabledFor(Level.ERROR)) log.error("while using TransferableToken.dataFlavor", e); //$NON-NLS-1$ // } catch (UnsupportedFlavorException ufe) { // ufe.printStackTrace(); // } catch (IOException ioe) { // ioe.printStackTrace(); } } else if (t.isDataFlavorSupported(GroupTokenTransferData.GROUP_TOKEN_LIST_FLAVOR)) { tokens = getTokens(t); // Tokens from Init Tool all need to be configured. configureTokens = new ArrayList<Boolean>(tokens.size()); for (int i = 0; i < tokens.size(); i++) { configureTokens.add(true); } } else { TabletopTool.showWarning("TransferableHelper.warning.badObject"); //$NON-NLS-1$ } } return tokens != null; } /** * @see javax.swing.TransferHandler#getSourceActions(javax.swing.JComponent) */ @Override public int getSourceActions(JComponent c) { return NONE; } /** @return Getter for tokens */ public List<Token> getTokens() { return tokens; } /** * @param tokens * Setter for tokens */ public void setTokens(List<Token> tokens) { // This doesn't appear to be called from anywhere; this class simply makes assignments // to the instance member variable. Remove this method? this.tokens = tokens; } /** @return Getter for configureTokens */ public List<Boolean> getConfigureTokens() { return configureTokens; } /** * @param configureTokens * Setter for configureTokens */ public void setConfigureTokens(List<Boolean> configureTokens) { this.configureTokens = configureTokens; } }