Unverified Commit e1c94a3d authored by Evan W. Patton's avatar Evan W. Patton Committed by GitHub

Add drag-and-drop of resources into App Inventor workspace (#2150)

* Add drag-and-drop of resources into App Inventor workspace

This commit improves the usability of App Inventor by enabling drag
and drop capabilities using HTML5. Users can drag projects, keystores,
extensions, and assets into the App Inventor workspace to upload
them. Extensions and assets are only added if the project editor is
open.

Change-Id: I5373a384cb8d115b23f946659b3151a2040b42b8

* Support drag and drop with uri-lists

Change-Id: Ia9e5d800760736a220e2795c4868b393b82afd4a
parent ce9cb23a
......@@ -47,6 +47,7 @@ import com.google.appinventor.client.output.OdeLog;
import com.google.appinventor.client.settings.Settings;
import com.google.appinventor.client.settings.user.UserSettings;
import com.google.appinventor.client.tracking.Tracking;
import com.google.appinventor.client.utils.HTML5DragDrop;
import com.google.appinventor.client.utils.PZAwarePositionCallback;
import com.google.appinventor.client.widgets.boxes.Box;
import com.google.appinventor.client.widgets.boxes.ColumnLayout;
......@@ -1254,6 +1255,7 @@ public class Ode implements EntryPoint {
});
setupMotd();
HTML5DragDrop.init();
}
private void setupMotd() {
......
......@@ -53,6 +53,10 @@ public interface OdeMessages extends Messages, AutogeneratedOdeMessages {
@Description("Text on \"Delete Project\" button")
String deleteProjectButton();
@DefaultMessage("Overwrite")
@Description("Text on \"Overwrite\" button")
String overwriteButton();
// a new button for trash
@DefaultMessage("View Trash")
@Description("Text on \"Trash\" button")
......
......@@ -6,13 +6,16 @@
package com.google.appinventor.client.explorer.youngandroid;
import com.google.appinventor.client.GalleryClient;
import com.google.appinventor.client.Ode;
import static com.google.appinventor.client.Ode.MESSAGES;
import com.google.appinventor.client.GalleryClient;
import com.google.appinventor.client.Ode;
import com.google.appinventor.client.explorer.project.Project;
import com.google.appinventor.client.explorer.project.ProjectComparators;
import com.google.appinventor.client.explorer.project.ProjectManagerEventListener;
import com.google.appinventor.shared.rpc.ServerLayout;
import com.google.gwt.core.client.GWT;
import com.google.gwt.dom.client.Element;
import com.google.gwt.event.dom.client.ClickEvent;
import com.google.gwt.event.dom.client.ClickHandler;
import com.google.gwt.event.dom.client.MouseDownEvent;
......@@ -35,6 +38,8 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static com.google.appinventor.client.Ode.MESSAGES;
/**
* The project list shows all projects in a table.
*
......@@ -330,6 +335,11 @@ public class ProjectList extends Composite implements ProjectManagerEventListene
} else {
table.getRowFormatter().setStyleName(row, "ode-ProjectRowUnHighlighted");
pw.checkBox.setValue(false);
table.getRowFormatter().getElement(row).setAttribute("data-exporturl",
"application/octet-stream:" + project.getProjectName() + ".aia:"
+ GWT.getModuleBaseURL() + ServerLayout.DOWNLOAD_SERVLET_BASE
+ ServerLayout.DOWNLOAD_PROJECT_SOURCE + "/" + project.getProjectId());
configureDraggable(table.getRowFormatter().getElement(row));
}
pw.checkBox.setName(String.valueOf(row));
if (row >= previous_rowmax) {
......@@ -431,4 +441,13 @@ public class ProjectList extends Composite implements ProjectManagerEventListene
public void setPublishedHeaderVisible(boolean visible){
table.getWidget(0, 4).setVisible(visible);
}
private static native void configureDraggable(Element el)/*-{
if (el.getAttribute('draggable') != 'true') {
el.setAttribute('draggable', 'true');
el.addEventListener('dragstart', function(e) {
e.dataTransfer.setData('DownloadURL', this.dataset.exporturl);
});
}
}-*/;
}
// -*- mode: java; c-basic-offset: 2; -*-
// Copyright 2017-2020 MIT, All rights reserved
// Released under the Apache License, Version 2.0
// http://www.apache.org/licenses/LICENSE-2.0
package com.google.appinventor.client.utils;
import com.google.appinventor.client.ErrorReporter;
import com.google.appinventor.client.Ode;
import com.google.appinventor.client.OdeAsyncCallback;
import com.google.appinventor.client.editor.youngandroid.YaBlocksEditor;
import com.google.appinventor.client.explorer.project.Project;
import com.google.appinventor.client.wizards.ComponentImportWizard.ImportComponentCallback;
import com.google.appinventor.shared.rpc.UploadResponse;
import com.google.appinventor.shared.rpc.project.UserProject;
import com.google.appinventor.shared.rpc.project.youngandroid.YoungAndroidAssetNode;
import com.google.appinventor.shared.rpc.project.youngandroid.YoungAndroidProjectNode;
import com.google.appinventor.shared.storage.StorageUtil;
import com.google.gwt.core.client.GWT;
import com.google.gwt.event.dom.client.ClickEvent;
import com.google.gwt.event.dom.client.ClickHandler;
import com.google.gwt.query.client.builders.JsniBundle;
import com.google.gwt.user.client.Window;
import com.google.gwt.user.client.ui.Button;
import com.google.gwt.user.client.ui.DialogBox;
import com.google.gwt.user.client.ui.DockPanel;
import com.google.gwt.user.client.ui.Label;
import com.google.gwt.user.client.ui.VerticalPanel;
import jsinterop.annotations.JsFunction;
import static com.google.appinventor.client.Ode.MESSAGES;
/**
* HTML5DragDrop implements support for dragging projects/extensions/assets from the developer's
* computer into the browser and dropping them onto the workspace. Depending on the extension of
* the file, one of uploadProject(), uploadExtension(), or uploadMedia() is called to trigger an
* import of the dropped entity.
*
* Compatibility
* -------------
*
* According to Mozilla, HTML5 Drag and Drop support is available starting in the following
* browser versions:
*
* Chrome: 4
* Edge: (always)
* Firefox: 3.5
* IE: 10
* Opera: 12
* Safari: 3.1
*/
public final class HTML5DragDrop {
interface HTML5DragDropSupport extends JsniBundle {
@LibrarySource("html5dnd.js")
void init();
}
@JsFunction
public interface ConfirmCallback {
void run();
}
public static void init() {
((HTML5DragDropSupport) GWT.create(HTML5DragDropSupport.class)).init();
initJsni();
}
private static native void initJsni()/*-{
top.HTML5DragDrop_isProjectEditorOpen =
$entry(@com.google.appinventor.client.utils.HTML5DragDrop::isProjectEditorOpen());
top.HTML5DragDrop_getOpenProjectId =
$entry(@com.google.appinventor.client.utils.HTML5DragDrop::getOpenProjectId());
top.HTML5DragDrop_handleUploadResponse =
$entry(@com.google.appinventor.client.utils.HTML5DragDrop::handleUploadResponse(*));
top.HTML5DragDrop_reportError =
$entry(@com.google.appinventor.client.utils.HTML5DragDrop::reportError(*));
top.HTML5DragDrop_confirmOverwriteKey =
$entry(@com.google.appinventor.client.utils.HTML5DragDrop::confirmOverwriteKey(*));
top.HTML5DragDrop_isBlocksEditorOpen =
$entry(@com.google.appinventor.client.utils.HTML5DragDrop::isBlocksEditorOpen());
}-*/;
public static boolean isProjectEditorOpen() {
return Ode.getInstance().getCurrentView() == 0;
}
public static boolean isBlocksEditorOpen() {
return isProjectEditorOpen()
&& Ode.getInstance().getCurrentFileEditor() instanceof YaBlocksEditor;
}
public static String getOpenProjectId() {
return Long.toString(Ode.getInstance().getCurrentYoungAndroidProjectId());
}
protected static void reportError(int errorCode) {
switch (errorCode) {
case 1:
Window.alert("No project open to receive upload.");
break;
case 2:
Window.alert("Uploading of APK files is not supported.");
break;
default:
Window.alert("Unexpected HTTP error code: " + errorCode);
}
}
protected static void confirmOverwriteKey(final ConfirmCallback callback) {
Ode.getInstance().getUserInfoService().hasUserFile(StorageUtil.ANDROID_KEYSTORE_FILENAME,
new OdeAsyncCallback<Boolean>(MESSAGES.uploadKeystoreError()) {
@Override
public void onSuccess(Boolean keystoreFileExists) {
if (keystoreFileExists) {
final DialogBox dialog = new DialogBox(false, true);
dialog.setStylePrimaryName("ode-DialogBox");
dialog.setText("Confirm Overwrite...");
Button cancelButton = new Button(MESSAGES.cancelButton());
Button deleteButton = new Button(MESSAGES.overwriteButton());
DockPanel buttonPanel = new DockPanel();
buttonPanel.add(cancelButton, DockPanel.WEST);
buttonPanel.add(deleteButton, DockPanel.EAST);
VerticalPanel panel = new VerticalPanel();
Label label = new Label();
label.setText(MESSAGES.confirmOverwriteKeystore());
panel.add(label);
panel.add(buttonPanel);
dialog.add(panel);
cancelButton.addClickHandler(new ClickHandler() {
@Override
public void onClick(ClickEvent event) {
dialog.hide();
}
});
deleteButton.addClickHandler(new ClickHandler() {
@Override
public void onClick(ClickEvent event) {
dialog.hide();
callback.run();
}
});
dialog.center();
dialog.show();
} else {
callback.run();
}
}
});
}
protected static void handleUploadResponse(String _projectId, String type, String name, String body) {
Ode ode = Ode.getInstance();
UploadResponse response = UploadResponse.extractUploadResponse(body);
if (response != null) {
switch (response.getStatus()) {
case SUCCESS:
ErrorReporter.hide();
if ("project".equals(type)) {
String info = response.getInfo();
UserProject userProject = UserProject.valueOf(info);
Project uploadedProject = ode.getProjectManager().addProject(userProject);
ode.openYoungAndroidProjectInDesigner(uploadedProject);
} else if ("extension".equals(type)) {
long projectId = Long.parseLong(_projectId);
YoungAndroidProjectNode projectNode = (YoungAndroidProjectNode) ode.getProjectManager()
.getProject(projectId).getRootNode();
ode.getComponentService().importComponentToProject(response.getInfo(), projectId,
projectNode.getAssetsFolder().getFileId(), new ImportComponentCallback());
} else if ("asset".equals(type)) {
long projectId = Long.parseLong(_projectId);
ode.updateModificationDate(projectId, response.getModificationDate());
Project project = ode.getProjectManager().getProject(projectId);
YoungAndroidProjectNode projectNode = (YoungAndroidProjectNode) project.getRootNode();
YoungAndroidAssetNode node = new YoungAndroidAssetNode(name,
projectNode.getAssetsFolder().getFileId() + "/" + name);
project.addNode(projectNode.getAssetsFolder(), node);
} else if ("keystore".equals(type)) {
Ode.getInstance().getTopToolbar().updateKeystoreFileMenuButtons();
}
break;
case FILE_TOO_LARGE:
ErrorReporter.reportInfo(MESSAGES.fileTooLargeError());
break;
case NOT_PROJECT_ARCHIVE:
ErrorReporter.reportInfo(MESSAGES.notProjectArchiveError());
break;
default:
ErrorReporter.reportError(MESSAGES.fileUploadError());
}
} else {
ErrorReporter.reportError(MESSAGES.fileUploadError());
}
}
}
// -*- mode: java; c-basic-offset: 2; -*-
// Copyright 2017-2020 MIT, All rights reserved
// Released under the Apache License, Version 2.0
// http://www.apache.org/licenses/LICENSE-2.0
/**
* Placeholder for type checking. Actual function is defined in GWT.
* @return {boolean}
*/
top.HTML5DragDrop_isProjectEditorOpen = function() { return false; };
/**
* Placeholder for type checking. Actual function is defined in GWT.
* @return {boolean}
*/
top.HTML5DragDrop_isBlocksEditorOpen = function() { return false; };
/**
* Placeholder for type checking. Actual function is defined in GWT.
* @return {string}
*/
top.HTML5DragDrop_getOpenProjectId = function() { return ''; };
top.HTML5DragDrop_handleUploadResponse = function(_projectId, type, name, response) {};
top.HTML5DragDrop_reportError = function(errorCode) {};
top.HTML5DragDrop_confirmOverwriteKey = function(callback) {};
var dropdiv = document.createElement('div');
dropdiv.className = 'dropdiv';
dropdiv.innerHTML = '<div><p>Drop files here</p></div>';
function isUrl(str) {
return str.indexOf('http:') === 0 || str.indexOf('https:') === 0;
}
function readUrl(item, cb) {
var xhr = new XMLHttpRequest();
xhr.open('GET', item, true);
xhr.responseType = 'blob';
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
if (xhr.response.name === undefined) {
xhr.response.name = item.substr(item.lastIndexOf('/') + 1);
}
cb(xhr.response);
}
}
};
xhr.send(null);
}
function handleDroppedItem(item, cb) {
if (isUrl(item.name)) {
readUrl(item.name, cb);
} else {
cb(item);
}
}
function importProject(droppedItem) {
function doImportProject(blob) {
var xhr = new XMLHttpRequest();
var formData = new FormData();
var filename = blob.name;
filename = filename.substr(filename.lastIndexOf('/') + 1);
formData.append('uploadProjectArchive', blob);
xhr.open('POST', '/ode/upload/project/' + filename.substr(0, filename.length - 4));
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
top.HTML5DragDrop_handleUploadResponse(null, 'project', droppedItem.name, xhr.response);
} else {
top.HTML5DragDrop_reportError(xhr.status);
}
}
};
xhr.send(formData);
}
handleDroppedItem(droppedItem, doImportProject);
}
function uploadExtension(droppedItem) {
if (!top.HTML5DragDrop_isProjectEditorOpen()) {
top.HTML5DragDrop_reportError(1);
return;
}
function doUploadExtension(blob) {
var projectId = top.HTML5DragDrop_getOpenProjectId();
var xhr = new XMLHttpRequest();
var formData = new FormData();
formData.append('uploadComponentArchive', blob);
xhr.open('POST', '/ode/upload/component/' + blob.name);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
top.HTML5DragDrop_handleUploadResponse(projectId, 'extension', blob.name, xhr.response);
} else {
top.HTML5DragDrop_reportError(xhr.status);
}
}
};
xhr.send(formData);
}
handleDroppedItem(droppedItem, doUploadExtension);
}
function uploadAsset(droppedItem) {
if (!top.HTML5DragDrop_isProjectEditorOpen()) {
top.HTML5DragDrop_reportError(1);
return;
}
function doUploadAsset(blob) {
var projectId = top.HTML5DragDrop_getOpenProjectId();
var xhr = new XMLHttpRequest();
var formData = new FormData();
formData.append('uploadFile', blob);
xhr.open('POST', '/ode/upload/file/' + projectId + '/assets/' + blob.name);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
top.HTML5DragDrop_handleUploadResponse(projectId, 'asset', blob.name, xhr.response);
} else {
top.HTML5DragDrop_reportError(xhr.status);
}
}
};
xhr.send(formData);
}
handleDroppedItem(droppedItem, doUploadAsset);
}
function uploadKeystore(droppedItem) {
function doUploadKeystore(blob) {
var xhr = new XMLHttpRequest();
var formData = new FormData();
formData.append('uploadUserFile', blob);
xhr.open('POST', '/ode/upload/userfile/android.keystore');
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
top.HTML5DragDrop_handleUploadResponse(null, 'keystore', blob.name, xhr.response);
} else {
top.HTML5DragDrop_reportError(xhr.status);
}
}
};
xhr.send(formData);
}
handleDroppedItem(droppedItem, doUploadKeystore);
}
function isProject(item) {
return goog.string.endsWith(item.name, '.aia');
}
function isExtension(item) {
return goog.string.endsWith(item.name, '.aix');
}
function isKeystore(item) {
return goog.string.endsWith(item.name, 'android.keystore');
}
function checkValidDrag(e) {
e.preventDefault();
var dragType = 'none';
if (e.dataTransfer.types.indexOf('Files') >= 0 ||
e.dataTransfer.types.indexOf('text/uri-list') >= 0) {
dragType = 'copy';
dropdiv.className = 'dropdiv good';
}
e.dataTransfer.dropEffect = dragType;
}
function doUploadKeystore(item) {
return function() {
uploadKeystore(item);
};
}
function checkValidDrop(e) {
e.preventDefault();
function process(item) {
if (isProject(item)) {
importProject(item);
} else if (isKeystore(item)) {
top.HTML5DragDrop_confirmOverwriteKey(doUploadKeystore(item));
} else if (isExtension(item) && top.HTML5DragDrop_isProjectEditorOpen()) {
uploadExtension(item);
} else if (goog.string.endsWith(item.name, '.apk')) {
top.HTML5DragDrop_reportError(2);
} else if (top.HTML5DragDrop_isProjectEditorOpen()) {
uploadAsset(item);
} else {
top.HTML5DragDrop_reportError(1);
}
}
if (e.dataTransfer.types.indexOf('Files') >= 0) {
for (var i = 0; i < e.dataTransfer.files.length; i++) {
process(e.dataTransfer.files[i]);
}
} else if (e.dataTransfer.types.indexOf('text/uri-list') >= 0) {
process({name: e.dataTransfer.getData('text/uri-list')});
}
}
var dragId = null;
function targetIsBlocksEditor(el) {
if (top.HTML5DragDrop_isBlocksEditorOpen()) {
while (el && el.tagName !== 'BODY') {
if (el.tagName === 'SVG' && el.classList.contains('blocklySvg')) {
return true;
} else if (el.tagName === 'DIV' && el.classList.contains('ode-Box')
&& el.querySelector('svg.blocklySvg')) {
return true;
}
el = el.parentElement;
}
}
return false;
}
function targetIsGwtDialogBox(e) {
if (e.path) {
for (var i = e.path.length - 1; i > 0; i--) {
if (e.path[i].classList && e.path[i].classList.contains('ode-DialogBox')) {
return true;
}
}
} else {
var el = e.target;
while (el && el.tagName !== 'BODY') {
if (el.classList.contains('ode-DialogBox')) {
return true;
}
el = el.parentElement;
}
}
return false;
}
/**
*
* @param {DragEvent} e
*/
function onDragEnter(e) {
var el = /** @type {HTMLElement} */ e.target;
if (targetIsBlocksEditor(el)) {
console.log('target is blocks editor');
return; // Allow for blocks editor to handle block png drag and drop
}
if (document.querySelector('.ode-DialogBox')) {
return; // dialog box is open
}
dragId = setTimeout(function() {
if (el.tagName !== 'INPUT' && !dropdiv.parentNode) {
document.body.appendChild(dropdiv);
}
}, 50);
checkValidDrag(e);
}
function onDragOver(e) {
var el = /** @type {HTMLElement} */ e.target;
if (targetIsBlocksEditor(el) || targetIsGwtDialogBox(e)) {
dropdiv.className = 'dropdiv';
if (dragId) {
clearTimeout(dragId);
dragId = null;
}
return; // Allow for blocks editor to handle block png drag and drop
}
checkValidDrag(e);
}
function onDragLeave(e) {
e.preventDefault();
var node = e.target;
if (node === dropdiv || node.querySelector('.ode-DeckPanel')
|| (e.path && e.path.length <= 10)) {
dropdiv.className = 'dropdiv';
dropdiv.remove();
}
}
function onDrop(e) {
try {
if (!targetIsBlocksEditor(e.target) && !targetIsGwtDialogBox(e)) { // blocks editor handles its own drop
checkValidDrop(e);
}
} finally {
dropdiv.remove();
}
}
function cancelDrop(e) {
if (dropdiv.classList.contains('good')) {
if (e.buttons === 0) {
dropdiv.remove();
dropdiv.className = 'dropdiv';
}
}
}
top.document.body.addEventListener('dragenter', onDragEnter, false);
top.document.body.addEventListener('dragover', onDragOver, true);
top.document.body.addEventListener('dragleave', onDragLeave, true);
top.document.body.addEventListener('drop', onDrop, true);
top.document.body.addEventListener('mousemove', cancelDrop);
......@@ -49,7 +49,7 @@ public class ComponentImportWizard extends Wizard {
final static String external_components = "assets/external_comps/";
private static class ImportComponentCallback extends OdeAsyncCallback<ComponentImportResponse> {
public static class ImportComponentCallback extends OdeAsyncCallback<ComponentImportResponse> {
@Override
public void onSuccess(ComponentImportResponse response) {
if (response.getStatus() == ComponentImportResponse.Status.FAILED){
......
......@@ -47,11 +47,13 @@ public final class TextValidators {
* @param projectName the project name to validate
* @return {@code true} if the project name is valid, {@code false} otherwise
*/
public static boolean checkNewProjectName(String projectName) {
public static boolean checkNewProjectName(String projectName, boolean quietly) {
// Check the format of the project name
if (!isValidIdentifier(projectName)) {
Window.alert(MESSAGES.malformedProjectNameError());
if (!quietly) {
Window.alert(MESSAGES.malformedProjectNameError());
}
return false;
}
......@@ -65,7 +67,7 @@ public final class TextValidators {
if (Ode.getInstance().getProjectManager().getProject(projectName) != null) {
if (Ode.getInstance().getProjectManager().getProject(projectName).isInTrash()) {
Window.alert(MESSAGES.duplicateTrashProjectNameError(projectName));
} else {
} else if (!quietly) {
Window.alert(MESSAGES.duplicateProjectNameError(projectName));
}
return false;
......@@ -74,6 +76,10 @@ public final class TextValidators {
return true;
}
public static boolean checkNewProjectName(String projectName) {
return checkNewProjectName(projectName, false);
}
public static boolean checkNewComponentName(String componentName) {
// Check that it meets the formatting requirements.
......
......@@ -2752,3 +2752,37 @@ div.vector-marker>svg, div.leaflet-marker-icon>img {
box-sizing: border-box;
padding: 2px;
}
div.dropdiv {
position: fixed;
z-index: 1000000;
width: 100%;
height: 100%;
box-sizing: border-box;
top: 0;
left: 0;
display: none;
pointer-events: none;
}
div.dropdiv.good {
background-color: rgba(128, 255, 128, 0.3);
border: 8px dashed darkgreen;
display: block;
}
div.dropdiv div {
position: relative;
top: 50%;
width: 100%;
text-align: center;
pointer-events: none;
}
div.dropdiv p {
text-align: center;
top: -15pt;
font-size: 24pt;
line-height: 30pt;
color: darkgreen;
}
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment