CSDN博客

img CanFly

Drag and Drop: New Data Transfer Capabilities in the JavaTM 2 Platform, Standard Edition (J2SETM), version 1.4

发表于2001/12/10 17:11:00  1106人阅读

分类: java

New Data Transfer Capabilities

John Zukowski
November 2001

Sun is currently undergoing their third beta release of the JavaTM 2 Platform, Standard Edition (J2SETM), version 1.4. This latest release incorporates many new and interesting features into the standard library set, some of which have been available as standard extensions for some time. These would include capabilities like the Java Cryptography Extension (JCE), Java Secure Socket Extension (JSSE), Java Authentication and Authorization Service (JAAS), the Java API for XML Processing (JAXP), and JDBC extensions. All of these libraries have been available as optional libraries and work with prior versions of the Java 2 Platform. Now, they just come standard.

In addition to adding previously optional packages to the standard library set, the 1.4 release also incorporates many new features. These totally new elements include new Swing components, new I/O management, and a new assert keyword in the language. Features like these are not backward compatible and are not available as optional libraries for earlier releases of the Java 2 Platform.

One of the new capabilities now available is improved data transfer support within and between applications. Data transfer includes two tasks: cut, copy, and paste access to the user or system clipboard and drag-and-drop support. Neither of these features is totally new to the standard library set. However, what you can do with them now in the 1.4 release and how you interact with the features has totally changed. Older programs, designed for the earlier data transfer mechanism, will continue to work fine, but in many cases the source code for the task can be simplified considerably.

Before looking into how to use the drag-and-drop capabilities of the J2SE, version 1.4 release, there is some background information you need to understand.

Clipboards

First off is a look at clipboards. If you aren't familiar with what a clipboard is, it is a memory area for storing information in support of cut, copy, and paste operations. For instance, when you are using a word processor and cut a piece of text to place the text elsewhere in the document, the text that was cut is saved within the clipboard. When it becomes time to paste the text, the paste operation reads what is on the clipboard and pastes it into the document.

There are actually multiple clipboards available. There is one global clipboard that all applications have access to, and private clipboards that can be limited in scope to within a single application. By utilizing a private clipboard in an application, you don't copy into system memory what goes onto the system clipboard. Instead, it stays within the application's memory space.

Transferable Objects

While you now know what a clipboard is, you haven't learned anything about the objects that can go into the clipboard. With the Java platform, objects that can be placed within a clipboard must implement the Transferable interface, found in the java.awt.datatransfer package.

public interface Transferable {
  public Object getTransferData(DataFlavor flavor) 
    throws UnsupportedFlavorException, IOException;
  public DataFlavor[] getTransferDataFlavors();
  public boolean isDataFlavorSupported(DataFlavor
         flavor);
}

The interface talks about an object called DataFlavor, but what's a flavor? Quite simply, data flavors are ways of representing data. In the Web world, you might have heard the term MIME types, where MIME stands for Multipurpose Internet Mail Extensions. MIME types allow you to say that an email attachment is an image or the Web document to deliver through your web server is a text file. Data flavors are just the way of representing MIME types within your programs.

MIME types have textual representations like text/html or text/plain. Data flavors on the other hand are classes. Flavors get created by textual representations, but they are classes in the system. They are used by the data transfer mechanism so that when you place something on the clipboard, you can state what type of object it is. Then, when someone gets something off the clipboard, that someone can ask what flavors of data are available. If all the flavors are ones they don't support, they can't get the object off the clipboard.

What's this about multiple flavors? You provide data through the data transfer mechanism with multiple flavors so that you can support the widest possible audience. For instance, if you were to copy text within a word processor, you would want to retain the formatting information. However, if you were to paste the text into something that didn't support the formatting, wouldn't you at least want to be able to get the actual text content? That's how flavors work. You specify that the transferable data can be represented by one or more flavors. You provide the means to check if a particular flavor is supported, and then you provide the mechanism to get the data for a specific flavor. And that is exactly what the Transferable interface does for you.

DataFlavor itself is a class. It makes several default flavor and MIME types available through class constants and a method. Going from simplest to most complex, you get predefined flavors for plain text, strings, a file list, and images. There are also three MIME types defined for local, serialized, and remote objects. From the MIME types, you can create the flavors.

  • getTextPlainUnicodeFlavor
  • stringFlavor
  • javaFileListFlavor
  • imageFlavor
  • javaJVMLocalObjectMimeType
  • javaSerializedObjectMimeType
  • javaRemoteObjectMimeType

Using the word processor example, imagine if that word processor was your program and you wanted to support cut and paste of the content of your document. You might present the content in four different flavors. The first flavor would be the local object type. If cut-and-paste is happening within the same application, using the javaJVMLocalObjectMimeType allows you to just use a local memory reference for the copy operation. Going between different Java applications would allow you to create a flavor from javaSerializedObjectMimeType. Here, the content is serialized, and you would be able to preserve any formatting information. The last two are stringFlavor and getTextPlainUnicodeFlavor. Both lose all formatting information, but still permit you to copy the contents.

Transferring string objects is a common task so you'll find a StringSelection class in the java.awt.datatransfer package to help you. As it only works with strings, the class doesn't support the local/serialized types just mentioned.

Now that you have some background information, let's move on to some real code.

Buffers

The clipboard acts as an indirect buffer. When you make something available to the clipboard, you don't actually copy the data there. Instead, you copy a reference and that reference is accessed when you want to take something off the buffer. Because you may need to keep track of that external reference, the clipboard will actually notify you when something else replaces an item on the clipboard. That notification is handled by the ClipboardOwner interface:

public interface ClipboardOwner {
  public void lostOwnership(Clipboard clip, 
    Transferable t);
}

This means that there is no built-in support for having multiple items on the clipboard. When something new is added to the clipboard, the old reference is thrown away.

To demonstrate the use of the system clipboard, take a look at the StringSelection class. It implements both the Transferable and ClipboardOwner interfaces. You use it to transfer strings to the clipboard. Adding a string to the clipboard involves creating a StringSelection item and setting the clipboard contents, through its setContents method. Here's what the code for that task looks like.

// Get String
String selection = ...;
// Convert to StringSelection
StringSelection data = new StringSelection(selection);
// Get system clipboard
Clipboard clipboard =
  Toolkit.getDefaultToolkit().getSystemClipboard();
// Set contents
clipboard.setContents(data, data);

Getting the clipboard data is a little more involved, but not too difficult. There are basically three steps involved:

  • Get the reference to the data from the clipboard, the Transferable object.
  • Find a flavor you can handle that it supports, where DataFlavor.stringFlavor is for text strings.
  • Get the data for that flavor.

And, here's what that code looks like. There's also a bit of exception handling necessary as a UnsupportedFlavorException or IOException could be thrown.

// Get data from clipboard
Transferable clipData = 
  clipboard.getContents(clipboard);
// Make sure not empty
if (clipData != null) {
// Check if it supports the desired flavor
  if (clipData.isDataFlavorSupported
       (DataFlavor.stringFlavor)) {
// Get data
    String s = (String)(clipData.getTransferData(
      DataFlavor.stringFlavor));
// Use data
    ...
  }
}

Clipboard Text Example

As shown in the following figure, the sample program that puts all these pieces together involves a text area and two buttons, Copy and Paste. When the Copy button is selected, the current selection within the text area is copied to the system clipboard. When the Paste button is selected, the current selection will be replaced with the contents of the system clipboard. If the flavor of data on the clipboard is not supported, the system beeps. Most of the code involves just the screen setup. There should be no new code as far as the clipboard access.

Clipboard Sample Screen

And, here's the complete source.

import java.awt.*;
import java.awt.event.*;
import java.awt.datatransfer.*;
import javax.swing.*;

public class ClipboardExample {
  public static void main(String args[]) {

    JFrame frame = new JFrame("Copy/Paste");
    Container contentPane = frame.getContentPane();

    final Toolkit kit = Toolkit.getDefaultToolkit();
    final Clipboard clipboard =
      kit.getSystemClipboard();

    final JTextArea jt = new JTextArea();

    JScrollPane pane = new JScrollPane(jt);
    contentPane.add(pane, BorderLayout.CENTER); 

    JPanel bottom = new JPanel();

    JButton copy = new JButton("Copy");
    bottom.add(copy);

    ActionListener copyListener = new 
      ActionListener() {
      public void actionPerformed(ActionEvent e) {
        String selection = jt.getSelectedText();
        StringSelection data = new 
          StringSelection(selection);
        clipboard.setContents(data, data);
      }
    };

    copy.addActionListener(copyListener);

    JButton paste = new JButton("Paste");
    bottom.add(paste);

    ActionListener pasteListener = new 
      ActionListener() {
      public void actionPerformed(ActionEvent 
        actionEvent) {
        Transferable clipData = 
          clipboard.getContents(clipboard);
        if (clipData != null) {
          try {
            if (clipData.isDataFlavorSupported(
                DataFlavor.stringFlavor)) {
              String s = 
                (String)(clipData.getTransferData(
                DataFlavor.stringFlavor));
              jt.replaceSelection(s);
            } else {
              kit.beep();
            }
          } catch (Exception e) {
            System.err.println("Problems getting data:
              " + e);
          }
        }
      }
    };

    paste.addActionListener(pasteListener);

    contentPane.add(bottom, BorderLayout.SOUTH);

    frame.setDefaultCloseOperation
      (JFrame.EXIT_ON_CLOSE);
    frame.setSize(300, 300);
    frame.show();
  }
}

When trying out the program, be sure to try to copy and paste between native applications like your word processor. Because you are using the system clipboard, the program will transfer whatever the contents of the clipboard are.

Text Actions

While this article is about explaining the data transfer mechanisms with the J2SE libraries, the code for copy and paste doesn't have to be that complicated when using the Swing text controls. They already know how to do cut, copy, and paste operations, among many other tasks. All you have to do is lookup the appropriate listener and add that instead.

The actions associated with the text controls are retrieved through the getActions method. This returns an array of Action objects, which implement the ActionListener interface. You just need to find the specific listener and attach that instead. The following code will do just that. It replaces the creation of the two ActionListener objects and their attachment in the prior example.

// get command table
Action actions[] = jt.getActions();

// Find the two wanted
int count = 

   0; for
(int i = 
 0, n = actions.length; (i< 
    n)  && (count <  2); 
    i++) {
  Action a = actions[i];
  String name = (String)a.getValue(Action.NAME);
  if (name.equals(DefaultEditorKit.copyAction)) {
    copy.addActionListener(a);
    count++;
  } else if (name.equals
      (DefaultEditorKit.pasteAction)) {
    paste.addActionListener(a);
    count++;
  }
}

Transferring Images

Now that the basic process of transferring text has been explained, let's move on to something new to the 1.4 release of J2SE, images transfers. Prior versions of the J2SE didn't provide integrated support for transferring images between Java programs and native applications. While you could go through the manual process of providing the data in an understandable format, that task is no longer necessary. All you now have to do is specify that the flavor is of type DataFlavor.imageFlavor, provide the data to the clipboard as an AWT Image object, and you're all set.

While the process of transferring a String is done through the StringSelection class, there is no such class available for transferring images. You must create your own Transferable object that implements ClipboardOwner, too. If StringSelection is for transferring String objects, the logical name for transferring images is ImageSelection.

If you plan on creating transferable objects that will interact with Swing components, you don't have to implement the two interfaces yourself. Instead, there is a helper TransferHandler class that does most of the work for you. The handler does the interaction with the clipboard, and you just have to override four of its methods:

  • public int getSourceActions(JComponent c) -- Returns the supported operations. There are four constants in the TransferHandler class for the operations: COPY, COPY_OR_MOVE, MOVE, and NONE.
  • public boolean canImport(JComponent comp, DataFlavor flavor[]) -- Returns true if the component can support one of the data flavors, false otherwise.
  • public Transferable createTransferable(JComponent comp) -- Here, you need to save a reference to the data to be transferred, and return the TransferHandler (this). The component represents where the data is coming from. This is your copy operation. The handler does the actual copy to the clipboard at the appropriate time.
  • public boolean importData(JComponent comp, Transferable t) -- Returns true if the component supports getting one of the data flavors from the Transferable object, and successfully gets it, false otherwise. This is your paste operation. Again, the handler gets the data from the clipboard, you just have to get it from the Transferable.

Here's just such a class definition that works with all AbstractButton subclasses and the JLabel component.

import java.awt.*;
import java.awt.datatransfer.*;
import java.io.*;
import javax.swing.*;

public class ImageSelection extends TransferHandler
    implements Transferable {

  private static final DataFlavor flavors[] = 
     {DataFlavor.imageFlavor};

  private Image image;

  public int getSourceActions(JComponent c) {
    return TransferHandler.COPY;
  }

  public boolean canImport(JComponent comp, DataFlavor 
    flavor[]) {
    if (!(comp instanceof JLabel) || 
         (comp instanceof AbstractButton)) {
      return false;
    }
    for (int i=0, n=flavor.length; i<n; i++) {
      if (flavor[i].equals(flavors[0])) {
        return true;
      }
    }
    return false;
  }

  public Transferable createTransferable(JComponent 
    comp) {
    // Clear
    image = null;
    Icon icon = null;

    if (comp instanceof JLabel) {
      JLabel label = (JLabel)comp;
      icon = label.getIcon();
    } else if (comp instanceof AbstractButton) {
      AbstractButton button = (AbstractButton)comp;
      icon = button.getIcon();
    }
    if (icon instanceof ImageIcon) {
      image = ((ImageIcon)icon).getImage();
      return this;
    }
    return null;
  }

  public boolean importData(JComponent comp, 
    Transferable t) {
    ImageIcon icon = null;
    try {
      if (t.isDataFlavorSupported(flavors[0])) {
        image = (Image)t.getTransferData(flavors[0]);
        icon = new ImageIcon(image);
      }
      if (comp instanceof JLabel) {
        JLabel label = (JLabel)comp;
        label.setIcon(icon);
        return true;
      } else if (comp instanceof AbstractButton) {
        AbstractButton button = (AbstractButton)comp;
        button.setIcon(icon);
        return true;
      }
    } catch (UnsupportedFlavorException ignored) {
    } catch (IOException ignored) {
    }
    return false;
  }

  // Transferable
  public Object getTransferData(DataFlavor flavor) {
    if (isDataFlavorSupported(flavor)) {
      return image;
    }
    return null;
  }

  public DataFlavor[] getTransferDataFlavors() {
    return flavors;
  }

  public boolean isDataFlavorSupported(DataFlavor 
    flavor) {
    return flavor.equals(flavors[0]);
  }
}

The following program demonstrates the ImageSelection class. There will be both a JLabel and JButton that uses the ImageSelection as its transfer handler. In the case of the label, three buttons will support different tasks: copying the image on the label to the clipboard, pasting an image on the clipboard to the label, and clearing the label. For the button, selection will act as a paste operation.

Associating an ImageSelection as the transfer handler for a component isn't sufficient for the behavior part of the program to work. You must create an ActionListener to associate to the button and call the necessary TransferHandler method to move the data. As previously mentioned, exportToClipboard will copy the image off the component, and importData will paste it. Like with pasting strings, you still need to get the Transferable off the clipboard though.

TransferHandler handler = label.getTransferHandler();
// Copy
handler.exportToClipboard(label, clipboard, 
  TransferHandler.COPY);
// Paste
Transferable clipData = 
  clipboard.getContents(clipboard);
if (clipData != null) {
  if (clipData.isDataFlavorSupported
    (DataFlavor.imageFlavor)) {
    handler.importData(label, clipData);
  }
}

To support pasting with the button component, you could do the same thing, but it isn't necessary. If the component that is to trigger the transfer is the source of the event, all you have to do is get the ActionListener right from the TransferHandler. There are three static methods for just such an operation: getCopyAction, getCutAction, and getPasteAction. Here's all the code that is necessary to have button selection trigger pasting of the clipboard contents to the button, setting its icon label.

JButton pasteB = new JButton("Paste");
pasteB.setTransferHandler(new ImageSelection());
pasteB.addActionListener
  (TransferHandler.getPasteAction());

Putting all this together becomes a complete test program:

import java.awt.*;
import java.awt.event.*;
import java.awt.datatransfer.*;
import javax.swing.*;
import java.io.IOException;

public class ImageCopy {

  public static void main(String args[]) {

    JFrame frame = new JFrame("Copy Image");
    frame.setDefaultCloseOperation
      (JFrame.EXIT_ON_CLOSE);

    Container contentPane = frame.getContentPane();

    Toolkit kit = Toolkit.getDefaultToolkit();
    final Clipboard clipboard =
      kit.getSystemClipboard();

    Icon icon = new ImageIcon("scott.jpg");
    final JLabel label = new JLabel(icon);
    label.setTransferHandler(new ImageSelection());

    JScrollPane pane = new JScrollPane(label);
    contentPane.add(pane, BorderLayout.CENTER); 

    JButton copy = new JButton("Label Copy");
    copy.addActionListener(new ActionListener() {
      public void actionPerformed(ActionEvent e) {
        TransferHandler handler = 
          label.getTransferHandler();
        handler.exportToClipboard(label, clipboard, 
          TransferHandler.COPY);
      }
    });

    JButton clear = new JButton("Label Clear");
    clear.addActionListener(new ActionListener() {
      public void actionPerformed(ActionEvent 
        actionEvent) {
        label.setIcon(null);
      }
    });

    JButton paste = new JButton("Label Paste");
    paste.addActionListener(new ActionListener() {
      public void actionPerformed(ActionEvent 
        actionEvent) {
        Transferable clipData = 
          clipboard.getContents(clipboard);
        if (clipData != null) {
          if (clipData.isDataFlavorSupported
            (DataFlavor.imageFlavor)) {
            TransferHandler handler = 
              label.getTransferHandler();
            handler.importData(label, clipData);
          }
        }
      }
    });

    JPanel p = new JPanel();
    p.add(copy);
    p.add(clear);
    p.add(paste);
    contentPane.add(p, BorderLayout.NORTH);

    JPanel pasteP = new JPanel();
    JButton pasteB = new JButton("Paste");

    pasteB.setTransferHandler(new ImageSelection());

    pasteB.addActionListener
      (TransferHandler.getPasteAction());

    pasteP.add(pasteB);
    contentPane.add(pasteB, BorderLayout.SOUTH);

    frame.setSize(400, 400);
    frame.show();
  }
}

When run, the program will look like the following screen, however, your images will probably differ. Try to copy only small images to the button as it isn't in a JScrollPane. Only the label supports large images.

Image Copy Sample Screen

Drag & Drop

This leads us up to how the 1.4 release of the J2SE supports drag-and-drop operations. The drag-and-drop operation is the transferring of data between objects, triggered by a gesture of the mouse or other pointing device. Prior versions of the platform required intricate work to get drag-and-drop going. For the new release, times have changed considerably. In some cases, all you have to do is call the setDragEnabled method of the component. In other cases, you need to associate a TransferHandler to the component. In all cases, the data to transfer must implement the Transferable interface, but now in many cases that part is already done.

For instance, if you want to support drag-and-drop operations within a JTextField, all you have to do is call the setDragEnabled method of the class, and the contents of the field are draggable.

JTextField tf = new JTextField();
tf.setDragEnabled(true);

This default behavior is available with many of the Swing components:

  • JColorChooser
  • JEditorPane
  • JFileChooser
  • JFormattedTextField
  • JList
  • JPasswordField
  • JTable
  • JTextArea
  • JTextField
  • JTextPane
  • JTree

Here's a simple program to demonstrate how easy drag-and-drop operations are with the new release of J2SE. It provides a JTextField and a JTree, both of which have drag enabled. The JTextField also supports drop operations, so you can drag something from the tree to the text field, but you must do additional work to enable drop on a JTree component.

import java.awt.*;
import java.awt.datatransfer.*;
import javax.swing.*;

public class DragOne {

  public static void main(String args[]) {

    JFrame frame = new JFrame("First Drag");
    frame.setDefaultCloseOperation
      (JFrame.EXIT_ON_CLOSE);

    Container contentPane = frame.getContentPane();

    JTree tree = new JTree();
    JScrollPane pane = new JScrollPane(tree);
    contentPane.add(pane, BorderLayout.CENTER);
    tree.setDragEnabled(true);

    JTextField tf = new JTextField();
    tf.setDragEnabled(true);
    contentPane.add(tf, BorderLayout.NORTH);

    frame.setSize(300, 300);
    frame.show();
  }
}

In order to drag a node from the tree, you must select the icon associated with the specific node. Also, be sure to try to drag multiple selected nodes from the JTree component. This will drag the entries as an ordered HTML list.

While some components have built-in support for drag-and-drop operations, not all do. For instance, if you wanted to support dragging the image on a JLabel, you would have to initiate the drag operation yourself when the mouse was pressed over the component.

MouseListener mouseListener = new MouseAdapter() {
  public void mousePressed(MouseEvent e) {
    JComponent comp = (JComponent)e.getSource();
    TransferHandler handler = 
      comp.getTransferHandler();
    handler.exportAsDrag(comp, e, 
      TransferHandler.COPY);
  }
};
label.addMouseListener(mouseListener);

As long as you associate the earlier ImageSelection handler as the TransferHandler for the component, you can drag off the image to a native application that accepts image input. It is that easy.

The ImageSelection handler is a little special in the sense that it doesn't transfer a property of the button/label. Instead it gets the Image from the Icon property of the component. When the transferable object actually is a property of the component, the creation of the handler can be done much more easily. The TransferHandler class has a constructor that accepts a property name as the constructor argument. Then, when you associate the handler with a Swing component, it knows how to acquire the appropriate content.

For instance, if you wanted to support the dragging of the text label for a JLabel, the following would work to create the TransferHandler for the label and associate it with the component:

TransferHandler handler = new TransferHandler("text");
JLabel label = new JLabel("Welcome");
label.setTransferHandler(handler);

You would still need to add the MouseListener to the component, but you don't have to do anything special to create the handler for the component.

If a Swing component doesn't provide built in drop support, you have to add that in yourself. The code to get the data being transferred via the drag-and-drop operation is identical to getting the data from the clipboard. You only need to associate the behavior with some triggering action, like when the mouse is released over a component:

MouseListener releaseListener = new MouseAdapter() {
  public void mouseReleased(MouseEvent e) {
    Transferable clipData = clipboard.getContents
      (clipboard);
    if (clipData != null) {
      if (clipData.isDataFlavorSupported
        (DataFlavor.imageFlavor)) {
        TransferHandler handler = 
          component.getTransferHandler();
        handler.importData(component, clipData);
      }
    }
  }
};

The following sample program combines all of these operations. There is one draggable text label, one draggable image label, one droppable image label, and a text field for dropping the text.

import java.awt.*;
import java.awt.event.*;
import java.awt.datatransfer.*;
import javax.swing.*;

public class DragTwo {

  public static void main(String args[]) {

    JFrame frame = new JFrame("Second Drag");
    frame.setDefaultCloseOperation
      (JFrame.EXIT_ON_CLOSE);

    Container contentPane = frame.getContentPane();

    Toolkit kit = Toolkit.getDefaultToolkit();
    final Clipboard clipboard =
      kit.getSystemClipboard();

    JTextField tf = new JTextField();
    contentPane.add(tf, BorderLayout.NORTH);

    Icon icon = new ImageIcon("scott.jpg");
    JLabel label1 = new JLabel(icon);
    label1.setTransferHandler(new ImageSelection());

    MouseListener pressListener = new MouseAdapter() {
      public void mousePressed(MouseEvent e) {
        JComponent comp = (JComponent)e.getSource();
        TransferHandler handler = 
          comp.getTransferHandler();
        handler.exportAsDrag
          (comp, e, TransferHandler.COPY);
      }
    };
    label1.addMouseListener(pressListener);

    TransferHandler handler = new 
      TransferHandler("text");
    JLabel label2 = new JLabel("Drag Me");
    label2.setTransferHandler(handler);
    label2.addMouseListener(pressListener);

    JPanel panel = new JPanel();
    panel.add(label1);
    panel.add(label2);
    contentPane.add(panel, BorderLayout.SOUTH);

    final JLabel dropZone = new JLabel();
    dropZone.setTransferHandler(new ImageSelection());
    MouseListener releaseListener = 
      new MouseAdapter() {
      public void mouseReleased(MouseEvent e) {
        Transferable clipData = clipboard.getContents
          (clipboard);
        if (clipData != null) {
          if (clipData.isDataFlavorSupported
            (DataFlavor.imageFlavor)) {
            TransferHandler handler = 
              dropZone.getTransferHandler();
            handler.importData(dropZone, clipData);
          }
        }
      }
    };
    dropZone.addMouseListener(releaseListener);

    JScrollPane pane = new JScrollPane(dropZone);
    contentPane.add(pane, BorderLayout.CENTER);

    frame.setSize(400, 400);
    frame.show();
  }
}

As shown here, the new J2SE, version 1.4 release makes adding drag-and-drop support to your applications very, very simple. With the added support for transferring images through either the system clipboard or through drag-and-drop operations, what more could you ask for? Throw in all the other new features being added to the 1.4 release and it is sure to be a success.

RESOURCES

0 0

相关博文

我的热门文章

img
取 消
img