Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add file upload for block factory #2320

Merged
merged 3 commits into from
Apr 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions examples/developer-tools/src/backwards_compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ const CONNECTION_CHECK_SHADOW = {
export function convertBaseBlock(
oldBlock: Blockly.serialization.blocks.State,
): Blockly.serialization.blocks.State {
if (oldBlock.type !== 'factory_base') {
throw Error('Malformed block data');
}

const newBlock = {...oldBlock};
// extraState from the old tool isn't relevant.
delete newBlock.extraState;
Expand Down
109 changes: 107 additions & 2 deletions examples/developer-tools/src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@

import * as Blockly from 'blockly';
import * as storage from './storage';
import {createNewBlock, loadBlock} from './serialization';
import {createNewBlock, loadBlock, loadBlockFromData} from './serialization';
import {ViewModel} from './view_model';
import {JavascriptDefinitionGenerator} from './output-generators/javascript_definition_generator';
import {JsonDefinitionGenerator} from './output-generators/json_definition_generator';
import {CodeHeaderGenerator} from './output-generators/code_header_generator';
import {Menu} from '@material/web/menu/menu';
import {GeneratorStubGenerator} from './output-generators/generator_stub_generator';
import {convertBaseBlock} from './backwards_compatibility';

const IMPORT_BLOCK_FACTORY_ID = 'Import from block factory...';

/**
* This class handles updating the UI output, including refreshing the block preview,
Expand Down Expand Up @@ -51,6 +54,33 @@ export class Controller {
this.handleLoadButton();
});

this.viewModel.fileModalCloseButton.addEventListener('click', () => {
this.viewModel.toggleFileUploadModal(false);
});

this.viewModel.fileDropZone.addEventListener('drop', (e) => {
this.handleFileDrop(e);
});

this.viewModel.fileUploadInput.addEventListener('change', () => {
this.handleFileUpload();
});

this.viewModel.fileLabel.addEventListener('keydown', function (ev) {
// Clicks the file input if you hit enter on the label for the input
if (ev.key === 'Enter') (ev.target as HTMLElement).click();
});

this.viewModel.fileDropZone.addEventListener('dragover', (ev) => {
ev.preventDefault();
this.viewModel.fileDropZone.classList.add('isDragging');
});

this.viewModel.fileDropZone.addEventListener('dragleave', (ev) => {
ev.preventDefault();
this.viewModel.fileDropZone.classList.remove('isDragging');
});

// Load previously-saved settings once on page load
this.loadBlockFactorySettings();
}
Expand Down Expand Up @@ -210,6 +240,10 @@ export class Controller {
private handleLoadSelect(e: Event) {
if (e.target && e.target instanceof HTMLElement) {
const blockName = e.target.getAttribute('data-id');
if (blockName === IMPORT_BLOCK_FACTORY_ID) {
this.handleLoadFromFile();
return;
}
loadBlock(this.mainWorkspace, blockName);
}
}
Expand All @@ -222,7 +256,9 @@ export class Controller {
*/
private populateLoadMenu() {
const menuEl = this.viewModel.loadMenu;
const newItems = Array.from(storage.getAllSavedBlockNames()).map((name) => {
const optionNames = Array.from(storage.getAllSavedBlockNames());
optionNames.push(IMPORT_BLOCK_FACTORY_ID);
const newItems = optionNames.map((name) => {
const el = document.createElement('md-menu-item');
el.setAttribute('data-id', name);
const div = document.createElement('div');
Expand All @@ -236,4 +272,73 @@ export class Controller {
});
menuEl.replaceChildren(...newItems);
}

/** Shows the file upload modal when the user selects that option from the load menu. */
private handleLoadFromFile() {
this.viewModel.toggleFileUploadModal(true);
}

/**
* Handles the drag event that occurs when a user drops a file onto the file upload
* drag-n-drop zone. Gets the first file of the type we want and attempts to load it
* into the block factory editor.
*
* @param e drop event
*/
private handleFileDrop(e: DragEvent) {
// Prevent default behavior (Prevent file from being opened)
e.preventDefault();

this.viewModel.toggleFileUploadWarning(false);

this.viewModel.fileDropZone.classList.remove('isDragging');

if (!e.dataTransfer.items) return;

const firstItem = [...e.dataTransfer.items].find((item) => {
BeksOmega marked this conversation as resolved.
Show resolved Hide resolved
// Get the first plain text file, in case the user uploaded multiple
if (item.kind === 'file' && item.type === 'text/plain') {
return true;
}
});

if (!firstItem) {
this.viewModel.toggleFileUploadWarning(true);
return;
}
this.loadFromFile(firstItem.getAsFile());
}

/**
* Handles the event that occurs when a user picks a file from the file input.
* Attempts to load the file into the block factory editor.
*/
private handleFileUpload() {
this.viewModel.toggleFileUploadWarning(false);
const input = this.viewModel.fileUploadInput as HTMLInputElement;
const file = input.files[0];
if (!file) return;
this.loadFromFile(file);
}

/**
* Given a file, tries to run it through our backwards-compatibility converter
* and load the block into block factory. If there's an error at any point,
* we show a warning and allow the user to try the upload again.
*
* @param file File containing the block json to load. This should be the file
* downloaded directly from the old block factory tool.
*/
private loadFromFile(file: File) {
file
.text()
.then((contents) => {
const fixedBlockData = convertBaseBlock(JSON.parse(contents));
loadBlockFromData(this.mainWorkspace, fixedBlockData);
this.viewModel.toggleFileUploadModal(false);
})
.catch((e) => {
this.viewModel.toggleFileUploadWarning(true);
});
}
}
52 changes: 52 additions & 0 deletions examples/developer-tools/src/index.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
:root {
--md-dialog-container-color: white;
}

body {
margin: 0;
}
Expand Down Expand Up @@ -83,6 +87,54 @@ md-menu {
max-height: 430px;
}

#file-upload-drop-zone {
background-color: white;
outline: 2px dashed #2bb4ca;
outline-offset: -10px;
border-radius: 8px;
min-height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

#file-upload-drop-zone > * {
flex-grow: 0;
margin: 12px 24px;
}
#file-upload-drop-zone > span {
font-size: 48px;
}

#file-upload-drop-zone.isDragging {
background-color: rgba(0, 0, 0, 0.1);
}

#file-label {
font-weight: 700;
cursor: pointer;
}

.warning-message {
visibility: hidden;
color: #b00020;
text-align: center;
}

/* Hides an element visually but not from screen readers */
.visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}

@media screen and (max-width: 900px) {
#block-factory-container {
flex-direction: column;
Expand Down
38 changes: 38 additions & 0 deletions examples/developer-tools/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Blockly Developer Tools</title>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Google+Symbols:opsz,wght,FILL,GRAD@48,400,0,0" />
</head>
<body>
<div class="page-container">
Expand All @@ -22,6 +25,41 @@
<button id="delete-btn" class="btn">Delete</button>
</div>
<div id="block-factory-container">
<md-dialog id="file-upload-modal">
<div slot="headline">Import from Block Factory</div>
<div slot="content">
<p>
You can upload a file from the legacy Block Factory to import that
block here.
</p>
<div id="file-upload-drop-zone">
<span class="google-symbols" aria-hidden="true">upload_file</span>
<input
type="file"
id="file-upload"
class="visually-hidden"
accept=".txt" />
<p>
<label for="file-upload" id="file-label" tabindex="1">
Choose a file
</label>
or drag it here
</p>
<p id="file-upload-warning" class="warning-message">
File could not be parsed. Make sure you're uploading the file
you downloaded from the legacy Block Factory.
</p>
</div>
</div>
<div slot="actions">
<md-text-button
id="file-upload-close"
form="form-id"
value="cancel">
Cancel
</md-text-button>
</div>
</md-dialog>
<div id="main-workspace"></div>
<div id="output-pane" class="panel-container">
<div id="block-preview"></div>
Expand Down
24 changes: 24 additions & 0 deletions examples/developer-tools/src/serialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,30 @@ export function loadBlock(workspace: Blockly.Workspace, blockName?: string) {
}
}

/**
* Loads given block json into the given workspace.
*
* @param workspace Blockly workspace to load into.
* @param blockJson Block json to load. This is state representing a single block,
* not the entire workspace.
*/
export function loadBlockFromData(
workspace: Blockly.Workspace,
blockJson: Blockly.serialization.blocks.State,
) {
// Disable events so we don't save while deleting blocks.
Blockly.Events.disable();
workspace.clear();
Blockly.Events.enable();

// There might be conflicts when loading a block from a file.
// If so, find a similar unused name so we don't overwrite.
const blockName = getNewUnusedName(blockJson.fields.NAME);
blockJson.fields.NAME = blockName;

Blockly.serialization.blocks.append(blockJson, workspace);
BeksOmega marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Creates a new block from scratch.
*
Expand Down
45 changes: 45 additions & 0 deletions examples/developer-tools/src/view_model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

import '@material/web/menu/menu';
import '@material/web/menu/menu-item';
import '@material/web/dialog/dialog';
import '@material/web/button/text-button';

export class ViewModel {
mainWorkspaceDiv = document.getElementById('main-workspace');
Expand All @@ -20,6 +22,13 @@ export class ViewModel {
loadButton = document.getElementById('load-btn');
loadMenu = document.getElementById('load-menu');

fileModal = document.getElementById('file-upload-modal');
fileModalCloseButton = document.getElementById('file-upload-close');
fileDropZone = document.getElementById('file-upload-drop-zone');
fileUploadInput = document.getElementById('file-upload');
fileLabel = document.getElementById('file-label');
fileUploadWarning = document.getElementById('file-upload-warning');

/**
* Gets a string representing the format of the block
* definition the user selected.
Expand Down Expand Up @@ -92,4 +101,40 @@ export class ViewModel {
? (importButton.checked = true)
: (scriptButton.checked = true);
}

/**
* Shows or hides the file upload modal.
*
* @param show true to show, false to hide.
*/
toggleFileUploadModal(show: boolean) {
if (show) {
this.fileModal.setAttribute('open', '');

// Set z-index of scrim so that it covers the Blockly toolbox
// https://github.com/material-components/material-web/issues/4948
if (this.fileModal.shadowRoot) {
const scrim =
this.fileModal.shadowRoot.querySelector<HTMLElement>('.scrim');
if (scrim) {
scrim.style.zIndex = '99';
}
}
} else {
this.fileModal.removeAttribute('open');
}
}

/**
* Shows or hides the file upload error message.
*
* @param show true to show, false to hide.
*/
toggleFileUploadWarning(show: boolean) {
if (show) {
this.fileUploadWarning.style.visibility = 'visible';
} else {
this.fileUploadWarning.style.visibility = 'hidden';
}
}
}
Loading