diff --git a/examples/developer-tools/src/backwards_compatibility.ts b/examples/developer-tools/src/backwards_compatibility.ts index 3a3a46845a..2d2b94b41d 100644 --- a/examples/developer-tools/src/backwards_compatibility.ts +++ b/examples/developer-tools/src/backwards_compatibility.ts @@ -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; diff --git a/examples/developer-tools/src/controller.ts b/examples/developer-tools/src/controller.ts index 3c33669587..bb4bf7945f 100644 --- a/examples/developer-tools/src/controller.ts +++ b/examples/developer-tools/src/controller.ts @@ -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, @@ -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(); } @@ -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); } } @@ -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'); @@ -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) => { + // 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); + }); + } } diff --git a/examples/developer-tools/src/index.css b/examples/developer-tools/src/index.css index 3d48bd6bc6..7997e55d00 100644 --- a/examples/developer-tools/src/index.css +++ b/examples/developer-tools/src/index.css @@ -1,3 +1,7 @@ +:root { + --md-dialog-container-color: white; +} + body { margin: 0; } @@ -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; diff --git a/examples/developer-tools/src/index.html b/examples/developer-tools/src/index.html index 864a7494d4..647f38561c 100644 --- a/examples/developer-tools/src/index.html +++ b/examples/developer-tools/src/index.html @@ -4,6 +4,9 @@ Blockly Developer Tools +
@@ -22,6 +25,41 @@
+ +
Import from Block Factory
+
+

+ You can upload a file from the legacy Block Factory to import that + block here. +

+
+ + +

+ + or drag it here +

+

+ File could not be parsed. Make sure you're uploading the file + you downloaded from the legacy Block Factory. +

+
+
+
+ + Cancel + +
+
diff --git a/examples/developer-tools/src/serialization.ts b/examples/developer-tools/src/serialization.ts index 126a2e12ae..dcafb48646 100644 --- a/examples/developer-tools/src/serialization.ts +++ b/examples/developer-tools/src/serialization.ts @@ -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); +} + /** * Creates a new block from scratch. * diff --git a/examples/developer-tools/src/view_model.ts b/examples/developer-tools/src/view_model.ts index b8c84cfaab..a5114ae89c 100644 --- a/examples/developer-tools/src/view_model.ts +++ b/examples/developer-tools/src/view_model.ts @@ -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'); @@ -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. @@ -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('.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'; + } + } }