From a5bfc969a8762686ad800133d1ad1a985eb67621 Mon Sep 17 00:00:00 2001 From: Richard Knoll Date: Wed, 15 Nov 2023 10:55:15 -0800 Subject: [PATCH 01/97] initial blockly work --- newblocks/builtins/functions.ts | 602 ++++++++ newblocks/builtins/lists.ts | 108 ++ newblocks/builtins/logic.ts | 58 + newblocks/builtins/loops.ts | 361 +++++ newblocks/builtins/math.ts | 296 ++++ newblocks/builtins/misc.ts | 184 +++ newblocks/builtins/text.ts | 43 + newblocks/builtins/variables.ts | 182 +++ newblocks/compiler/compiler.ts | 1333 +++++++++++++++++ newblocks/compiler/environment.ts | 223 +++ newblocks/compiler/typeChecker.ts | 746 +++++++++ newblocks/compiler/util.ts | 145 ++ newblocks/compiler/variables.ts | 279 ++++ newblocks/composableMutations.ts | 401 +++++ newblocks/constants.ts | 6 + newblocks/diff.ts | 547 +++++++ newblocks/external.ts | 30 + newblocks/fields/fieldEditorRegistry.ts | 75 + newblocks/fields/field_animation.ts | 275 ++++ newblocks/fields/field_argumentvariable.ts | 20 + newblocks/fields/field_asset.ts | 582 +++++++ newblocks/fields/field_autocomplete.ts | 193 +++ newblocks/fields/field_base.ts | 94 ++ newblocks/fields/field_breakpoint.ts | 161 ++ newblocks/fields/field_colorwheel.ts | 145 ++ newblocks/fields/field_colour.ts | 164 ++ newblocks/fields/field_gridpicker.ts | 650 ++++++++ newblocks/fields/field_imagedropdown.ts | 181 +++ newblocks/fields/field_images.ts | 137 ++ newblocks/fields/field_kind.ts | 225 +++ newblocks/fields/field_ledmatrix.ts | 350 +++++ newblocks/fields/field_melodySandbox.ts | 846 +++++++++++ newblocks/fields/field_musiceditor.ts | 136 ++ newblocks/fields/field_note.ts | 643 ++++++++ newblocks/fields/field_numberdropdown.ts | 46 + newblocks/fields/field_position.ts | 243 +++ newblocks/fields/field_procedure.ts | 78 + newblocks/fields/field_protractor.ts | 80 + newblocks/fields/field_sound_effect.ts | 440 ++++++ newblocks/fields/field_speed.ts | 100 ++ newblocks/fields/field_sprite.ts | 153 ++ newblocks/fields/field_styledlabel.ts | 32 + newblocks/fields/field_textdropdown.ts | 18 + newblocks/fields/field_textinput.ts | 10 + newblocks/fields/field_tilemap.ts | 162 ++ newblocks/fields/field_tileset.ts | 265 ++++ newblocks/fields/field_toggle.ts | 296 ++++ newblocks/fields/field_toggle_highlow.ts | 22 + newblocks/fields/field_toggle_onoff.ts | 22 + newblocks/fields/field_toggle_updown.ts | 38 + newblocks/fields/field_toggle_winlose.ts | 22 + newblocks/fields/field_toggle_yesno.ts | 22 + newblocks/fields/field_tsexpression.ts | 50 + newblocks/fields/field_turnratio.ts | 109 ++ newblocks/fields/field_userenum.ts | 173 +++ newblocks/fields/field_utils.ts | 471 ++++++ newblocks/help.ts | 80 + newblocks/importer.ts | 350 +++++ newblocks/layout.ts | 568 +++++++ newblocks/legacyMutations.ts | 677 +++++++++ newblocks/loader.ts | 1330 ++++++++++++++++ .../functions/blocks/argumentEditorBlocks.ts | 131 ++ .../blocks/argumentReporterBlocks.ts | 131 ++ .../functions/blocks/functionCallBlocks.ts | 210 +++ .../blocks/functionDeclarationBlock.ts | 247 +++ .../blocks/functionDefinitionBlock.ts | 141 ++ .../plugins/functions/commonFunctionMixin.ts | 395 +++++ newblocks/plugins/functions/constants.ts | 19 + newblocks/plugins/functions/extensions.ts | 63 + .../functions/fields/fieldArgumentEditor.ts | 102 ++ .../fields/fieldAutocapitalizeTextInput.ts | 45 + .../plugins/functions/functionManager.ts | 35 + newblocks/plugins/functions/index.ts | 16 + newblocks/plugins/functions/msg.ts | 51 + newblocks/plugins/functions/utils.ts | 363 +++++ newblocks/render.ts | 141 ++ newblocks/toolbox.ts | 434 ++++++ newblocks/tsconfig.json | 35 + newblocks/validation/validateBlockExists.ts | 40 + newblocks/xml.ts | 30 + package.json | 1 + 81 files changed, 18908 insertions(+) create mode 100644 newblocks/builtins/functions.ts create mode 100644 newblocks/builtins/lists.ts create mode 100644 newblocks/builtins/logic.ts create mode 100644 newblocks/builtins/loops.ts create mode 100644 newblocks/builtins/math.ts create mode 100644 newblocks/builtins/misc.ts create mode 100644 newblocks/builtins/text.ts create mode 100644 newblocks/builtins/variables.ts create mode 100644 newblocks/compiler/compiler.ts create mode 100644 newblocks/compiler/environment.ts create mode 100644 newblocks/compiler/typeChecker.ts create mode 100644 newblocks/compiler/util.ts create mode 100644 newblocks/compiler/variables.ts create mode 100644 newblocks/composableMutations.ts create mode 100644 newblocks/constants.ts create mode 100644 newblocks/diff.ts create mode 100644 newblocks/external.ts create mode 100644 newblocks/fields/fieldEditorRegistry.ts create mode 100644 newblocks/fields/field_animation.ts create mode 100644 newblocks/fields/field_argumentvariable.ts create mode 100644 newblocks/fields/field_asset.ts create mode 100644 newblocks/fields/field_autocomplete.ts create mode 100644 newblocks/fields/field_base.ts create mode 100644 newblocks/fields/field_breakpoint.ts create mode 100644 newblocks/fields/field_colorwheel.ts create mode 100644 newblocks/fields/field_colour.ts create mode 100644 newblocks/fields/field_gridpicker.ts create mode 100644 newblocks/fields/field_imagedropdown.ts create mode 100644 newblocks/fields/field_images.ts create mode 100644 newblocks/fields/field_kind.ts create mode 100644 newblocks/fields/field_ledmatrix.ts create mode 100644 newblocks/fields/field_melodySandbox.ts create mode 100644 newblocks/fields/field_musiceditor.ts create mode 100644 newblocks/fields/field_note.ts create mode 100644 newblocks/fields/field_numberdropdown.ts create mode 100644 newblocks/fields/field_position.ts create mode 100644 newblocks/fields/field_procedure.ts create mode 100644 newblocks/fields/field_protractor.ts create mode 100644 newblocks/fields/field_sound_effect.ts create mode 100644 newblocks/fields/field_speed.ts create mode 100644 newblocks/fields/field_sprite.ts create mode 100644 newblocks/fields/field_styledlabel.ts create mode 100644 newblocks/fields/field_textdropdown.ts create mode 100644 newblocks/fields/field_textinput.ts create mode 100644 newblocks/fields/field_tilemap.ts create mode 100644 newblocks/fields/field_tileset.ts create mode 100644 newblocks/fields/field_toggle.ts create mode 100644 newblocks/fields/field_toggle_highlow.ts create mode 100644 newblocks/fields/field_toggle_onoff.ts create mode 100644 newblocks/fields/field_toggle_updown.ts create mode 100644 newblocks/fields/field_toggle_winlose.ts create mode 100644 newblocks/fields/field_toggle_yesno.ts create mode 100644 newblocks/fields/field_tsexpression.ts create mode 100644 newblocks/fields/field_turnratio.ts create mode 100644 newblocks/fields/field_userenum.ts create mode 100644 newblocks/fields/field_utils.ts create mode 100644 newblocks/help.ts create mode 100644 newblocks/importer.ts create mode 100644 newblocks/layout.ts create mode 100644 newblocks/legacyMutations.ts create mode 100644 newblocks/loader.ts create mode 100644 newblocks/plugins/functions/blocks/argumentEditorBlocks.ts create mode 100644 newblocks/plugins/functions/blocks/argumentReporterBlocks.ts create mode 100644 newblocks/plugins/functions/blocks/functionCallBlocks.ts create mode 100644 newblocks/plugins/functions/blocks/functionDeclarationBlock.ts create mode 100644 newblocks/plugins/functions/blocks/functionDefinitionBlock.ts create mode 100644 newblocks/plugins/functions/commonFunctionMixin.ts create mode 100644 newblocks/plugins/functions/constants.ts create mode 100644 newblocks/plugins/functions/extensions.ts create mode 100644 newblocks/plugins/functions/fields/fieldArgumentEditor.ts create mode 100644 newblocks/plugins/functions/fields/fieldAutocapitalizeTextInput.ts create mode 100644 newblocks/plugins/functions/functionManager.ts create mode 100644 newblocks/plugins/functions/index.ts create mode 100644 newblocks/plugins/functions/msg.ts create mode 100644 newblocks/plugins/functions/utils.ts create mode 100644 newblocks/render.ts create mode 100644 newblocks/toolbox.ts create mode 100644 newblocks/tsconfig.json create mode 100644 newblocks/validation/validateBlockExists.ts create mode 100644 newblocks/xml.ts diff --git a/newblocks/builtins/functions.ts b/newblocks/builtins/functions.ts new file mode 100644 index 000000000000..70b6a8c6acf1 --- /dev/null +++ b/newblocks/builtins/functions.ts @@ -0,0 +1,602 @@ +/// + +import * as Blockly from "blockly" +import { installBuiltinHelpInfo } from "../help"; +import { FunctionManager } from "../plugins/functions/functionManager"; +import { createFlyoutGroupLabel, createFlyoutHeadingLabel, mkReturnStatementBlock } from "../toolbox"; +import { getAllFunctionDefinitionBlocks } from "../plugins/functions"; +import { FieldProcedure } from "../fields/field_procedure"; +import { cachedBlockInfo, setOutputCheck } from "../loader"; +import { domToWorkspaceNoEvents } from "../importer"; + +export function initFunctions() { + const msg = Blockly.Msg; + + // New functions implementation messages + msg.FUNCTION_CREATE_NEW = lf("Make a Function..."); + msg.FUNCTION_WARNING_DUPLICATE_ARG = lf("Functions cannot use the same argument name more than once."); + msg.FUNCTION_WARNING_ARG_NAME_IS_FUNCTION_NAME = lf("Argument names must not be the same as the function name."); + msg.FUNCTION_WARNING_EMPTY_NAME = lf("Function and argument names cannot be empty."); + msg.FUNCTIONS_DEFAULT_FUNCTION_NAME = lf("doSomething"); + msg.FUNCTIONS_DEFAULT_BOOLEAN_ARG_NAME = lf("bool"); + msg.FUNCTIONS_DEFAULT_STRING_ARG_NAME = lf("text"); + msg.FUNCTIONS_DEFAULT_NUMBER_ARG_NAME = lf("num"); + msg.FUNCTIONS_DEFAULT_CUSTOM_ARG_NAME = lf("arg"); + msg.PROCEDURES_HUE = pxt.toolbox.getNamespaceColor("functions"); + msg.REPORTERS_HUE = pxt.toolbox.getNamespaceColor("variables"); + + // builtin procedures_defnoreturn + const proceduresDefId = "procedures_defnoreturn"; + const proceduresDef = pxt.blocks.getBlockDefinition(proceduresDefId); + + msg.PROCEDURES_DEFNORETURN_TITLE = proceduresDef.block["PROCEDURES_DEFNORETURN_TITLE"]; + msg.PROCEDURE_ALREADY_EXISTS = proceduresDef.block["PROCEDURE_ALREADY_EXISTS"]; + + (Blockly.Blocks['procedures_defnoreturn']).init = function () { + let nameField = new Blockly.FieldTextInput('', + (Blockly as any).Procedures.rename); + //nameField.setSpellcheck(false); //TODO + this.appendDummyInput() + .appendField((Blockly as any).Msg.PROCEDURES_DEFNORETURN_TITLE) + .appendField(nameField, 'NAME') + .appendField('', 'PARAMS'); + this.setColour(pxt.toolbox.getNamespaceColor('functions')); + this.arguments_ = []; + this.argumentVarModels_ = []; + this.setStartHat(true); + this.setStatements_(true); + this.statementConnection_ = null; + }; + installBuiltinHelpInfo(proceduresDefId); + + // builtin procedures_defnoreturn + const proceduresCallId = "procedures_callnoreturn"; + const proceduresCallDef = pxt.blocks.getBlockDefinition(proceduresCallId); + + msg.PROCEDURES_CALLRETURN_TOOLTIP = proceduresDef.tooltip.toString(); + + Blockly.Blocks['procedures_callnoreturn'] = { + init: function () { + let nameField = new FieldProcedure(''); + this.appendDummyInput('TOPROW') + .appendField(proceduresCallDef.block['PROCEDURES_CALLNORETURN_TITLE']) + .appendField(nameField, 'NAME'); + this.setPreviousStatement(true); + this.setNextStatement(true); + this.setColour(pxt.toolbox.getNamespaceColor('functions')); + this.arguments_ = []; + this.quarkConnections_ = {}; + this.quarkIds_ = null; + }, + /** + * Returns the name of the procedure this block calls. + * @return {string} Procedure name. + * @this Blockly.Block + */ + getProcedureCall: function () { + // The NAME field is guaranteed to exist, null will never be returned. + return /** @type {string} */ (this.getFieldValue('NAME')); + }, + /** + * Notification that a procedure is renaming. + * If the name matches this block's procedure, rename it. + * @param {string} oldName Previous name of procedure. + * @param {string} newName Renamed procedure. + * @this Blockly.Block + */ + renameProcedure: function (oldName: string, newName: string) { + if (Blockly.Names.equals(oldName, this.getProcedureCall())) { + this.setFieldValue(newName, 'NAME'); + } + }, + /** + * Procedure calls cannot exist without the corresponding procedure + * definition. Enforce this link whenever an event is fired. + * @param {!Blockly.Events.Abstract} event Change event. + * @this Blockly.Block + */ + onchange: function (event: any) { + if (!this.workspace || this.workspace.isFlyout || this.isInsertionMarker()) { + // Block is deleted or is in a flyout or insertion marker. + return; + } + if (event.type == Blockly.Events.CREATE && + event.ids.indexOf(this.id) != -1) { + // Look for the case where a procedure call was created (usually through + // paste) and there is no matching definition. In this case, create + // an empty definition block with the correct signature. + let name = this.getProcedureCall(); + let def = Blockly.Procedures.getDefinition(name, this.workspace); + if (def && (def.type != this.defType_ || + JSON.stringify((def as any).arguments_) != JSON.stringify(this.arguments_))) { + // The signatures don't match. + def = null; + } + if (!def) { + Blockly.Events.setGroup(event.group); + /** + * Create matching definition block. + * + * + * test + * + * + */ + let xml = Blockly.utils.xml.createElement('xml'); + let block = Blockly.utils.xml.createElement('block'); + block.setAttribute('type', this.defType_); + let xy = this.getRelativeToSurfaceXY(); + let x = xy.x + (Blockly as any).SNAP_RADIUS * (this.RTL ? -1 : 1); + let y = xy.y + (Blockly as any).SNAP_RADIUS * 2; + block.setAttribute('x', x); + block.setAttribute('y', y); + let field = Blockly.utils.xml.createElement('field'); + field.setAttribute('name', 'NAME'); + field.appendChild(document.createTextNode(this.getProcedureCall())); + block.appendChild(field); + xml.appendChild(block); + domToWorkspaceNoEvents(xml, this.workspace); + Blockly.Events.setGroup(false); + } + } else if (event.type == Blockly.Events.DELETE) { + // Look for the case where a procedure definition has been deleted, + // leaving this block (a procedure call) orphaned. In this case, delete + // the orphan. + let name = this.getProcedureCall(); + let def = Blockly.Procedures.getDefinition(name, this.workspace); + if (!def) { + Blockly.Events.setGroup(event.group); + this.dispose(true, false); + Blockly.Events.setGroup(false); + } + } + }, + mutationToDom: function () { + const mutationElement = document.createElement("mutation"); + mutationElement.setAttribute("name", this.getProcedureCall()); + return mutationElement; + }, + domToMutation: function (element: Element) { + const name = element.getAttribute("name"); + this.renameProcedure(this.getProcedureCall(), name); + }, + /** + * Add menu option to find the definition block for this call. + * @param {!Array} options List of menu options to add to. + * @this Blockly.Block + */ + customContextMenu: function (options: any) { + let option: any = { enabled: true }; + option.text = (Blockly as any).Msg.PROCEDURES_HIGHLIGHT_DEF; + let name = this.getProcedureCall(); + let workspace = this.workspace; + option.callback = function () { + let def = Blockly.Procedures.getDefinition(name, workspace) as Blockly.BlockSvg; + if (def) def.select(); + }; + options.push(option); + }, + defType_: 'procedures_defnoreturn' + } + installBuiltinHelpInfo(proceduresCallId); + + // New functions implementation function_definition + const functionDefinitionId = "function_definition"; + const functionDefinition = pxt.blocks.getBlockDefinition(functionDefinitionId); + + msg.FUNCTIONS_EDIT_OPTION = functionDefinition.block["FUNCTIONS_EDIT_OPTION"]; + installBuiltinHelpInfo(functionDefinitionId); + + // New functions implementation function_call + const functionCallId = "function_call"; + const functionCall = pxt.blocks.getBlockDefinition(functionCallId); + + msg.FUNCTIONS_CALL_TITLE = functionCall.block["FUNCTIONS_CALL_TITLE"]; + msg.FUNCTIONS_GO_TO_DEFINITION_OPTION = functionCall.block["FUNCTIONS_GO_TO_DEFINITION_OPTION"]; + installBuiltinHelpInfo(functionCallId); + installBuiltinHelpInfo("function_call_output"); + + const functionReturnId = "function_return"; + Blockly.Blocks[functionReturnId] = { + init: function () { + initReturnStatement(this); + }, + onchange: function (event: Blockly.Events.Abstract) { + const block = this as Blockly.Block; + if (!block.workspace || (block.workspace as Blockly.WorkspaceSvg).isFlyout) { + // Block is deleted or is in a flyout. + return; + } + + const thisWasCreated = + event.type === Blockly.Events.BLOCK_CREATE && (event as Blockly.Events.BlockCreate).ids.indexOf(block.id) != -1; + const thisWasDragged = + event.type === Blockly.Events.BLOCK_DRAG && (event as Blockly.Events.BlockDrag).blocks.some(b => b.id === block.id); + + if (thisWasCreated || thisWasDragged) { + const rootBlock = block.getRootBlock(); + const isTopBlock = rootBlock.type === functionReturnId; + + if (isTopBlock || rootBlock.previousConnection != null) { + // Statement is by itself on the workspace, or it is slotted into a + // stack of statements that is not attached to a function or event. Let + // it exist until it is connected to a function + return; + } + + if (rootBlock.type !== functionDefinitionId) { + // Not a function block, so disconnect + Blockly.Events.setGroup(event.group); + block.previousConnection.disconnect(); + Blockly.Events.setGroup(false); + } + } + } + }; + installBuiltinHelpInfo(functionReturnId); + + Blockly.Procedures.flyoutCategory = function (workspace: Blockly.WorkspaceSvg) { + let xmlList: HTMLElement[] = []; + + if (!pxt.appTarget.appTheme.hideFlyoutHeadings) { + // Add the Heading label + let headingLabel = createFlyoutHeadingLabel(lf("Functions"), + pxt.toolbox.getNamespaceColor('functions'), + pxt.toolbox.getNamespaceIcon('functions'), + 'blocklyFlyoutIconfunctions'); + xmlList.push(headingLabel); + } + + const newFunction = lf("Make a Function..."); + const newFunctionTitle = lf("New function name:"); + + // Add the "Make a function" button + let button = Blockly.utils.xml.createElement('button'); + button.setAttribute('text', newFunction); + button.setAttribute('callbackKey', 'CREATE_FUNCTION'); + + let createFunction = (name: string) => { + /** + * Create matching definition block. + * + * + * test + * + * + */ + let topBlock = workspace.getTopBlocks(true)[0]; + let x = 10, y = 10; + if (topBlock) { + let xy = topBlock.getRelativeToSurfaceXY(); + x = xy.x + (Blockly as any).SNAP_RADIUS * (topBlock.RTL ? -1 : 1); + y = xy.y + (Blockly as any).SNAP_RADIUS * 2; + } + let xml = Blockly.utils.xml.createElement('xml'); + let block = Blockly.utils.xml.createElement('block'); + block.setAttribute('type', 'procedures_defnoreturn'); + block.setAttribute('x', String(x)); + block.setAttribute('y', String(y)); + let field = Blockly.utils.xml.createElement('field'); + field.setAttribute('name', 'NAME'); + field.appendChild(document.createTextNode(name)); + block.appendChild(field); + xml.appendChild(block); + let newBlockIds = domToWorkspaceNoEvents(xml, workspace); + // Close flyout and highlight block + Blockly.hideChaff(); + let newBlock = workspace.getBlockById(newBlockIds[0]) as Blockly.BlockSvg; + newBlock.select(); + // Center on the new block so we know where it is + workspace.centerOnBlock(newBlock.id); + } + + workspace.registerButtonCallback('CREATE_FUNCTION', function (button) { + let promptAndCheckWithAlert = (defaultName: string) => { + Blockly.dialog.prompt(newFunctionTitle, defaultName, function (newFunc) { + pxt.tickEvent('blocks.makeafunction'); + // Merge runs of whitespace. Strip leading and trailing whitespace. + // Beyond this, all names are legal. + if (newFunc) { + newFunc = newFunc.replace(/[\s\xa0]+/g, ' ').replace(/^ | $/g, ''); + if (newFunc == newFunction) { + // Ok, not ALL names are legal... + newFunc = null; + } + } + if (newFunc) { + if (workspace.getVariable(newFunc)) { + Blockly.dialog.alert(Blockly.Msg.VARIABLE_ALREADY_EXISTS.replace('%1', + newFunc.toLowerCase()), + function () { + promptAndCheckWithAlert(newFunc); // Recurse + }); + } + else if (!Blockly.Procedures.isNameUsed(newFunc, workspace)) { + Blockly.dialog.alert(Blockly.Msg.PROCEDURE_ALREADY_EXISTS.replace('%1', + newFunc.toLowerCase()), + function () { + promptAndCheckWithAlert(newFunc); // Recurse + }); + } + else { + createFunction(newFunc); + } + } + }); + }; + promptAndCheckWithAlert('doSomething'); + }); + xmlList.push(button as HTMLElement); + + function populateProcedures(procedureList: any, templateName: any) { + for (let i = 0; i < procedureList.length; i++) { + let name = procedureList[i][0]; + let args = procedureList[i][1]; + // + // name + // + let block = Blockly.utils.xml.createElement('block'); + block.setAttribute('type', templateName); + block.setAttribute('gap', '16'); + block.setAttribute('colour', pxt.toolbox.getNamespaceColor('functions')); + let field = Blockly.utils.xml.createElement('field') + field.textContent = name; + field.setAttribute('name', 'NAME'); + block.appendChild(field); + xmlList.push(block as HTMLElement); + } + } + + let tuple = Blockly.Procedures.allProcedures(workspace); + populateProcedures(tuple[0], 'procedures_callnoreturn'); + + return xmlList; + } + + // FIXME (riknoll) + // Patch new functions flyout to add the heading + // const oldFlyout = Blockly.Functions.flyoutCategory; + // Blockly.Functions.flyoutCategory = (workspace) => { + // const elems = oldFlyout(workspace); + + // if (elems.length > 1) { + // let returnBlock = mkReturnStatementBlock(); + // // Add divider + // elems.splice(1, 0, createFlyoutGroupLabel(lf("Your Functions"))); + // // Insert after the "make a function" button + // elems.splice(1, 0, returnBlock as HTMLElement); + // } + + // const functionsWithReturn = getAllFunctionDefinitionBlocks(workspace) + // .filter(def => def.getDescendants(false).some(child => child.type === "function_return" && child.getInputTargetBlock("RETURN_VALUE"))) + // .map(def => def.getField("function_name").getText()) + + // const headingLabel = createFlyoutHeadingLabel(lf("Functions"), + // pxt.toolbox.getNamespaceColor('functions'), + // pxt.toolbox.getNamespaceIcon('functions'), + // 'blocklyFlyoutIconfunctions'); + // elems.unshift(headingLabel); + + // const res: Element[] = []; + + // for (const e of elems) { + // res.push(e); + // if (e.getAttribute("type") === "function_call") { + // const mutation = e.children.item(0); + + // if (mutation) { + // const name = mutation.getAttribute("name"); + // if (functionsWithReturn.some(n => n === name)) { + // const clone = e.cloneNode(true) as HTMLElement; + // clone.setAttribute("type", "function_call_output"); + // res.push(clone); + // } + // } + // } + // } + + // return res; + // }; + + // Configure function editor argument icons + const iconsMap: pxt.Map = { + number: pxt.blocks.defaultIconForArgType("number"), + boolean: pxt.blocks.defaultIconForArgType("boolean"), + string: pxt.blocks.defaultIconForArgType("string"), + Array: pxt.blocks.defaultIconForArgType("Array") + }; + const customNames: pxt.Map = {}; + + const functionOptions = pxt.appTarget.runtime && pxt.appTarget.runtime.functionsOptions; + if (functionOptions && functionOptions.extraFunctionEditorTypes) { + functionOptions.extraFunctionEditorTypes.forEach(t => { + iconsMap[t.typeName] = t.icon || pxt.blocks.defaultIconForArgType(); + + if (t.defaultName) { + customNames[t.typeName] = t.defaultName; + } + }); + } + + for (const type of Object.keys(iconsMap)) { + FunctionManager.getInstance().setIconForType(type, iconsMap[type]); + } + + for (const type of Object.keys(customNames)) { + FunctionManager.getInstance().setArgumentNameForType(type, customNames[type]); + } + + if (Blockly.Blocks["argument_reporter_custom"]) { + // The logic for setting the output check relies on the internals of PXT + // too much to be refactored into pxt-blockly, so we need to monkey patch + // it here + (Blockly.Blocks["argument_reporter_custom"]).domToMutation = function (xmlElement: Element) { + const typeName = xmlElement.getAttribute('typename'); + this.typeName_ = typeName; + + setOutputCheck(this, typeName, cachedBlockInfo); + }; + } + + /** + * Make a context menu option for creating a function call block. + * This appears in the context menu for function definitions. + * @param {!Blockly.BlockSvg} block The block where the right-click originated. + * @return {!Object} A menu option, containing text, enabled, and a callback. + * @package + */ + const makeCreateCallOptionOriginal = (Blockly as any).Functions.makeCreateCallOption; + + // needs to exist or makeCreateCallOptionOriginal will throw an exception + Blockly.Msg.FUNCTIONS_CREATE_CALL_OPTION = ""; + + (Blockly as any).Functions.makeCreateCallOption = function (block: Blockly.Block) { + let option = makeCreateCallOptionOriginal(block); + + let functionName = block.getField("function_name").getText(); + option.text = pxt.Util.lf("Create 'call {0}'", functionName); + + return option; + } +} + +function initReturnStatement(b: Blockly.Block) { + const returnDef = pxt.blocks.getBlockDefinition("function_return"); + + const buttonAddName = "0_add_button"; + const buttonRemName = "0_rem_button"; + + Blockly.Extensions.apply('inline-svgs', b, false); + + let returnValueVisible = true; + + // When the value input is removed, we disconnect the block that was connected to it. This + // is the id of whatever block was last connected + let lastConnectedId: string; + + updateShape(); + + b.domToMutation = saved => { + if (saved.hasAttribute("last_connected_id")) { + lastConnectedId = saved.getAttribute("last_connected_id"); + } + returnValueVisible = hasReturnValue(saved); + updateShape(); + } + + b.mutationToDom = () => { + const mutation = document.createElement("mutation"); + setReturnValue(mutation, !!b.getInput("RETURN_VALUE")); + + if (lastConnectedId) { + mutation.setAttribute("last_connected_id", lastConnectedId); + } + + return mutation; + } + + function updateShape() { + const returnValueInput = b.getInput("RETURN_VALUE"); + + if (returnValueVisible) { + if (!returnValueInput) { + // Remove any labels + while (b.getInput("")) b.removeInput(""); + + b.jsonInit({ + "message0": returnDef.block["message_with_value"], + "args0": [ + { + "type": "input_value", + "name": "RETURN_VALUE", + "check": null + } + ], + "previousStatement": null, + "colour": pxt.toolbox.getNamespaceColor('functions') + }); + } + if (b.getInput(buttonAddName)) { + b.removeInput(buttonAddName); + } + if (!b.getInput(buttonRemName)) { + addMinusButton(); + } + + if (lastConnectedId) { + const lastConnected = b.workspace.getBlockById(lastConnectedId); + if (lastConnected && lastConnected.outputConnection && !lastConnected.outputConnection.targetBlock()) { + b.getInput("RETURN_VALUE").connection.connect(lastConnected.outputConnection); + } + lastConnectedId = undefined; + } + } + else { + if (returnValueInput) { + const target = returnValueInput.connection.targetBlock() + if (target) { + if (target.isShadow()) target.setShadow(false); + returnValueInput.connection.disconnect(); + lastConnectedId = target.id; + } + b.removeInput("RETURN_VALUE"); + b.jsonInit({ + "message0": returnDef.block["message_no_value"], + "args0": [], + "previousStatement": null, + "colour": pxt.toolbox.getNamespaceColor('functions') + }) + } + if (b.getInput(buttonRemName)) { + b.removeInput(buttonRemName); + } + if (!b.getInput(buttonAddName)) { + addPlusButton(); + } + } + + b.setInputsInline(true); + } + + function setReturnValue(mutation: Element, hasReturnValue: boolean) { + mutation.setAttribute("no_return_value", hasReturnValue ? "false" : "true") + } + + function hasReturnValue(mutation: Element) { + return mutation.getAttribute("no_return_value") !== "true" + } + + function addPlusButton() { + addButton(buttonAddName, (b as any).ADD_IMAGE_DATAURI, lf("Add return value")); + } + + function addMinusButton() { + addButton(buttonRemName, (b as any).REMOVE_IMAGE_DATAURI, lf("Remove return value")); + } + + function mutationString() { + return Blockly.Xml.domToText(b.mutationToDom()); + } + + function fireMutationChange(pre: string, post: string) { + if (pre !== post) + Blockly.Events.fire(new Blockly.Events.BlockChange(b, "mutation", null, pre, post)); + } + + function addButton(name: string, uri: string, alt: string) { + b.appendDummyInput(name) + .appendField(new Blockly.FieldImage(uri, 24, 24, alt, () => { + const oldMutation = mutationString(); + returnValueVisible = !returnValueVisible; + + const preUpdate = mutationString() + fireMutationChange(oldMutation, preUpdate); + + updateShape(); + + const postUpdate = mutationString(); + fireMutationChange(preUpdate, postUpdate); + + }, false)) + } +} \ No newline at end of file diff --git a/newblocks/builtins/lists.ts b/newblocks/builtins/lists.ts new file mode 100644 index 000000000000..da834929db25 --- /dev/null +++ b/newblocks/builtins/lists.ts @@ -0,0 +1,108 @@ +/// + +import * as Blockly from "blockly" +import { installBuiltinHelpInfo, setBuiltinHelpInfo } from "../help"; +import { provider } from "../constants"; + +export function initLists() { + const msg = Blockly.Msg; + + // lists_create_with + const listsCreateWithId = "lists_create_with"; + const listsCreateWithDef = pxt.blocks.getBlockDefinition(listsCreateWithId); + msg.LISTS_CREATE_EMPTY_TITLE = listsCreateWithDef.block["LISTS_CREATE_EMPTY_TITLE"]; + msg.LISTS_CREATE_WITH_INPUT_WITH = listsCreateWithDef.block["LISTS_CREATE_WITH_INPUT_WITH"]; + msg.LISTS_CREATE_WITH_CONTAINER_TITLE_ADD = listsCreateWithDef.block["LISTS_CREATE_WITH_CONTAINER_TITLE_ADD"]; + msg.LISTS_CREATE_WITH_ITEM_TITLE = listsCreateWithDef.block["LISTS_CREATE_WITH_ITEM_TITLE"]; + installBuiltinHelpInfo(listsCreateWithId); + + // lists_length + const listsLengthId = "lists_length"; + const listsLengthDef = pxt.blocks.getBlockDefinition(listsLengthId); + msg.LISTS_LENGTH_TITLE = listsLengthDef.block["LISTS_LENGTH_TITLE"]; + + // We have to override this block definition because the builtin block + // allows both Strings and Arrays in its input check and that confuses + // our Blockly compiler + let block = Blockly.Blocks[listsLengthId]; + block.init = function () { + this.jsonInit({ + "message0": msg.LISTS_LENGTH_TITLE, + "args0": [ + { + "type": "input_value", + "name": "VALUE", + "check": ['Array'] + } + ], + "output": 'Number', + "outputShape": provider.SHAPES.ROUND + }); + } + + installBuiltinHelpInfo(listsLengthId); + + // lists_index_get + const listsIndexGetId = "lists_index_get"; + const listsIndexGetDef = pxt.blocks.getBlockDefinition(listsIndexGetId); + Blockly.Blocks["lists_index_get"] = { + init: function () { + this.jsonInit({ + "message0": listsIndexGetDef.block["message0"], + "args0": [ + { + "type": "input_value", + "name": "LIST", + "check": "Array" + }, + { + "type": "input_value", + "name": "INDEX", + "check": "Number" + } + ], + "colour": pxt.toolbox.blockColors['arrays'], + "outputShape": provider.SHAPES.ROUND, + "inputsInline": true + }); + + this.setPreviousStatement(false); + this.setNextStatement(false); + this.setOutput(true); + setBuiltinHelpInfo(this, listsIndexGetId); + } + }; + + // lists_index_set + const listsIndexSetId = "lists_index_set"; + const listsIndexSetDef = pxt.blocks.getBlockDefinition(listsIndexSetId); + Blockly.Blocks[listsIndexSetId] = { + init: function () { + this.jsonInit({ + "message0": listsIndexSetDef.block["message0"], + "args0": [ + { + "type": "input_value", + "name": "LIST", + "check": "Array" + }, + { + "type": "input_value", + "name": "INDEX", + "check": "Number" + }, + { + "type": "input_value", + "name": "VALUE", + "check": null + } + ], + "previousStatement": null, + "nextStatement": null, + "colour": pxt.toolbox.blockColors['arrays'], + "inputsInline": true + }); + setBuiltinHelpInfo(this, listsIndexSetId); + } + }; +} \ No newline at end of file diff --git a/newblocks/builtins/logic.ts b/newblocks/builtins/logic.ts new file mode 100644 index 000000000000..3e056690023c --- /dev/null +++ b/newblocks/builtins/logic.ts @@ -0,0 +1,58 @@ +/// + +import * as Blockly from "blockly"; + +import { installBuiltinHelpInfo } from "../help"; + +export function initLogic() { + const msg = Blockly.Msg; + + // builtin controls_if + const controlsIfId = "controls_if"; + const controlsIfDef = pxt.blocks.getBlockDefinition(controlsIfId); + const controlsIfTooltips = controlsIfDef.tooltip as pxt.Map; + msg.CONTROLS_IF_MSG_IF = controlsIfDef.block["CONTROLS_IF_MSG_IF"]; + msg.CONTROLS_IF_MSG_THEN = controlsIfDef.block["CONTROLS_IF_MSG_THEN"]; + msg.CONTROLS_IF_MSG_ELSE = controlsIfDef.block["CONTROLS_IF_MSG_ELSE"]; + msg.CONTROLS_IF_MSG_ELSEIF = controlsIfDef.block["CONTROLS_IF_MSG_ELSEIF"]; + msg.CONTROLS_IF_TOOLTIP_1 = controlsIfTooltips["CONTROLS_IF_TOOLTIP_1"]; + msg.CONTROLS_IF_TOOLTIP_2 = controlsIfTooltips["CONTROLS_IF_TOOLTIP_2"]; + msg.CONTROLS_IF_TOOLTIP_3 = controlsIfTooltips["CONTROLS_IF_TOOLTIP_3"]; + msg.CONTROLS_IF_TOOLTIP_4 = controlsIfTooltips["CONTROLS_IF_TOOLTIP_4"]; + installBuiltinHelpInfo(controlsIfId); + + // builtin logic_compare + const logicCompareId = "logic_compare"; + const logicCompareDef = pxt.blocks.getBlockDefinition(logicCompareId); + const logicCompareTooltips = logicCompareDef.tooltip as pxt.Map; + msg.LOGIC_COMPARE_TOOLTIP_EQ = logicCompareTooltips["LOGIC_COMPARE_TOOLTIP_EQ"]; + msg.LOGIC_COMPARE_TOOLTIP_NEQ = logicCompareTooltips["LOGIC_COMPARE_TOOLTIP_NEQ"]; + msg.LOGIC_COMPARE_TOOLTIP_LT = logicCompareTooltips["LOGIC_COMPARE_TOOLTIP_LT"]; + msg.LOGIC_COMPARE_TOOLTIP_LTE = logicCompareTooltips["LOGIC_COMPARE_TOOLTIP_LTE"]; + msg.LOGIC_COMPARE_TOOLTIP_GT = logicCompareTooltips["LOGIC_COMPARE_TOOLTIP_GT"]; + msg.LOGIC_COMPARE_TOOLTIP_GTE = logicCompareTooltips["LOGIC_COMPARE_TOOLTIP_GTE"]; + installBuiltinHelpInfo(logicCompareId); + + // builtin logic_operation + const logicOperationId = "logic_operation"; + const logicOperationDef = pxt.blocks.getBlockDefinition(logicOperationId); + const logicOperationTooltips = logicOperationDef.tooltip as pxt.Map; + msg.LOGIC_OPERATION_AND = logicOperationDef.block["LOGIC_OPERATION_AND"]; + msg.LOGIC_OPERATION_OR = logicOperationDef.block["LOGIC_OPERATION_OR"]; + msg.LOGIC_OPERATION_TOOLTIP_AND = logicOperationTooltips["LOGIC_OPERATION_TOOLTIP_AND"]; + msg.LOGIC_OPERATION_TOOLTIP_OR = logicOperationTooltips["LOGIC_OPERATION_TOOLTIP_OR"]; + installBuiltinHelpInfo(logicOperationId); + + // builtin logic_negate + const logicNegateId = "logic_negate"; + const logicNegateDef = pxt.blocks.getBlockDefinition(logicNegateId); + msg.LOGIC_NEGATE_TITLE = logicNegateDef.block["LOGIC_NEGATE_TITLE"]; + installBuiltinHelpInfo(logicNegateId); + + // builtin logic_boolean + const logicBooleanId = "logic_boolean"; + const logicBooleanDef = pxt.blocks.getBlockDefinition(logicBooleanId); + msg.LOGIC_BOOLEAN_TRUE = logicBooleanDef.block["LOGIC_BOOLEAN_TRUE"]; + msg.LOGIC_BOOLEAN_FALSE = logicBooleanDef.block["LOGIC_BOOLEAN_FALSE"]; + installBuiltinHelpInfo(logicBooleanId); +} \ No newline at end of file diff --git a/newblocks/builtins/loops.ts b/newblocks/builtins/loops.ts new file mode 100644 index 000000000000..bc43e63ef107 --- /dev/null +++ b/newblocks/builtins/loops.ts @@ -0,0 +1,361 @@ +/// + +import * as Blockly from "blockly"; +import { installBuiltinHelpInfo, setBuiltinHelpInfo, setHelpResources } from "../help"; + +export function initLoops() { + const msg = Blockly.Msg; + + // controls_repeat_ext + const controlsRepeatExtId = "controls_repeat_ext"; + const controlsRepeatExtDef = pxt.blocks.getBlockDefinition(controlsRepeatExtId); + msg.CONTROLS_REPEAT_TITLE = controlsRepeatExtDef.block["CONTROLS_REPEAT_TITLE"]; + msg.CONTROLS_REPEAT_INPUT_DO = controlsRepeatExtDef.block["CONTROLS_REPEAT_INPUT_DO"]; + installBuiltinHelpInfo(controlsRepeatExtId); + + // device_while + const deviceWhileId = "device_while"; + const deviceWhileDef = pxt.blocks.getBlockDefinition(deviceWhileId); + Blockly.Blocks[deviceWhileId] = { + init: function () { + this.jsonInit({ + "message0": deviceWhileDef.block["message0"], + "args0": [ + { + "type": "input_value", + "name": "COND", + "check": "Boolean" + } + ], + "previousStatement": null, + "nextStatement": null, + "colour": pxt.toolbox.getNamespaceColor('loops') + }); + this.appendStatementInput("DO") + .appendField(deviceWhileDef.block["appendField"]); + + setBuiltinHelpInfo(this, deviceWhileId); + } + }; + + // pxt_controls_for + const pxtControlsForId = "pxt_controls_for"; + const pxtControlsForDef = pxt.blocks.getBlockDefinition(pxtControlsForId); + Blockly.Blocks[pxtControlsForId] = { + /** + * Block for 'for' loop. + * @this Blockly.Block + */ + init: function () { + this.jsonInit({ + "message0": pxtControlsForDef.block["message0"], + "args0": [ + { + "type": "input_value", + "name": "VAR", + "variable": pxtControlsForDef.block["variable"], + "check": "Variable" + }, + { + "type": "input_value", + "name": "TO", + "check": "Number" + } + ], + "previousStatement": null, + "nextStatement": null, + "colour": pxt.toolbox.getNamespaceColor('loops'), + "inputsInline": true + }); + this.appendStatementInput('DO') + .appendField(pxtControlsForDef.block["appendField"]); + + let thisBlock = this; + setHelpResources(this, + pxtControlsForId, + pxtControlsForDef.name, + function () { + return pxt.U.rlf(pxtControlsForDef.tooltip, + thisBlock.getInputTargetBlock('VAR') ? thisBlock.getInputTargetBlock('VAR').getField('VAR').getText() : ''); + }, + pxtControlsForDef.url, + String(pxt.toolbox.getNamespaceColor('loops')) + ); + }, + /** + * Return all variables referenced by this block. + * @return {!Array.} List of variable names. + * @this Blockly.Block + */ + getVars: function (): any[] { + return [this.getField('VAR').getText()]; + }, + /** + * Notification that a variable is renaming. + * If the name matches one of this block's variables, rename it. + * @param {string} oldName Previous name of variable. + * @param {string} newName Renamed variable. + * @this Blockly.Block + */ + renameVar: function (oldName: string, newName: string) { + const varField = this.getField('VAR'); + if (Blockly.Names.equals(oldName, varField.getText())) { + varField.setValue(newName); + } + } + }; + + // controls_simple_for + const controlsSimpleForId = "controls_simple_for"; + const controlsSimpleForDef = pxt.blocks.getBlockDefinition(controlsSimpleForId); + Blockly.Blocks[controlsSimpleForId] = { + /** + * Block for 'for' loop. + * @this Blockly.Block + */ + init: function () { + this.jsonInit({ + "message0": controlsSimpleForDef.block["message0"], + "args0": [ + { + "type": "field_variable", + "name": "VAR", + "variable": controlsSimpleForDef.block["variable"] + // Please note that most multilingual characters + // cannot be used as variable name at this point. + // Translate or decide the default variable name + // with care. + }, + { + "type": "input_value", + "name": "TO", + "check": "Number" + } + ], + "previousStatement": null, + "nextStatement": null, + "colour": pxt.toolbox.getNamespaceColor('loops'), + "inputsInline": true + }); + this.appendStatementInput('DO') + .appendField(controlsSimpleForDef.block["appendField"]); + + let thisBlock = this; + setHelpResources(this, + controlsSimpleForId, + controlsSimpleForDef.name, + function () { + return pxt.U.rlf(controlsSimpleForDef.tooltip, thisBlock.getField('VAR').getText()); + }, + controlsSimpleForDef.url, + String(pxt.toolbox.getNamespaceColor('loops')) + ); + }, + /** + * Return all variables referenced by this block. + * @return {!Array.} List of variable names. + * @this Blockly.Block + */ + getVars: function (): any[] { + return [this.getField('VAR').getText()]; + }, + /** + * Notification that a variable is renaming. + * If the name matches one of this block's variables, rename it. + * @param {string} oldName Previous name of variable. + * @param {string} newName Renamed variable. + * @this Blockly.Block + */ + renameVar: function (oldName: string, newName: string) { + const varField = this.getField('VAR'); + if (Blockly.Names.equals(oldName, varField.getText())) { + varField.setValue(newName); + } + }, + /** + * Add menu option to create getter block for loop variable. + * @param {!Array} options List of menu options to add to. + * @this Blockly.Block + */ + customContextMenu: function (options: any[]) { + if (!this.isCollapsed() && !this.inDebugWorkspace()) { + let option: any = { enabled: true }; + let name = this.getField('VAR').getText(); + option.text = lf("Create 'get {0}'", name); + let xmlField = Blockly.utils.xml.createElement('field'); + xmlField.textContent = name; + xmlField.setAttribute('name', 'VAR'); + let xmlBlock = Blockly.utils.xml.createElement('block') as HTMLElement; + xmlBlock.setAttribute('type', 'variables_get'); + xmlBlock.appendChild(xmlField); + option.callback = Blockly.ContextMenu.callbackFactory(this, xmlBlock); + options.push(option); + } + } + }; + + // break statement + const breakBlockDef = pxt.blocks.getBlockDefinition(ts.pxtc.TS_BREAK_TYPE); + Blockly.Blocks[pxtc.TS_BREAK_TYPE] = { + init: function () { + const color = pxt.toolbox.getNamespaceColor('loops'); + + this.jsonInit({ + "message0": breakBlockDef.block["message0"], + "inputsInline": true, + "previousStatement": null, + "nextStatement": null, + "colour": color + }); + + setHelpResources(this, + ts.pxtc.TS_BREAK_TYPE, + breakBlockDef.name, + breakBlockDef.tooltip, + breakBlockDef.url, + color, + undefined/*colourSecondary*/, + undefined/*colourTertiary*/, + false/*undeletable*/ + ); + } + } + + // continue statement + const continueBlockDef = pxt.blocks.getBlockDefinition(ts.pxtc.TS_CONTINUE_TYPE); + Blockly.Blocks[pxtc.TS_CONTINUE_TYPE] = { + init: function () { + const color = pxt.toolbox.getNamespaceColor('loops'); + + this.jsonInit({ + "message0": continueBlockDef.block["message0"], + "inputsInline": true, + "previousStatement": null, + "nextStatement": null, + "colour": color + }); + + setHelpResources(this, + ts.pxtc.TS_CONTINUE_TYPE, + continueBlockDef.name, + continueBlockDef.tooltip, + continueBlockDef.url, + color, + undefined/*colourSecondary*/, + undefined/*colourTertiary*/, + false/*undeletable*/ + ); + } + } + + const collapsedColor = "#cccccc"; + Blockly.Blocks[pxtc.COLLAPSED_BLOCK] = { + init: function () { + this.jsonInit({ + "message0": "...", + "inputsInline": true, + "previousStatement": null, + "nextStatement": null, + "colour": collapsedColor + }) + setHelpResources(this, + ts.pxtc.COLLAPSED_BLOCK, + "...", + lf("a few blocks"), + undefined, + collapsedColor, + undefined/*colourSecondary*/, + undefined/*colourTertiary*/, + false/*undeletable*/ + ); + } + } + + // pxt_controls_for_of + const pxtControlsForOfId = "pxt_controls_for_of"; + const pxtControlsForOfDef = pxt.blocks.getBlockDefinition(pxtControlsForOfId); + Blockly.Blocks[pxtControlsForOfId] = { + init: function () { + this.jsonInit({ + "message0": pxtControlsForOfDef.block["message0"], + "args0": [ + { + "type": "input_value", + "name": "VAR", + "variable": pxtControlsForOfDef.block["variable"], + "check": "Variable" + }, + { + "type": "input_value", + "name": "LIST", + "check": ["Array", "String"] + } + ], + "previousStatement": null, + "nextStatement": null, + "colour": pxt.toolbox.blockColors['loops'], + "inputsInline": true + }); + + this.appendStatementInput('DO') + .appendField(pxtControlsForOfDef.block["appendField"]); + + let thisBlock = this; + setHelpResources(this, + pxtControlsForOfId, + pxtControlsForOfDef.name, + function () { + return pxt.Util.rlf(pxtControlsForOfDef.tooltip as string, + thisBlock.getInputTargetBlock('VAR') ? thisBlock.getInputTargetBlock('VAR').getField('VAR').getText() : ''); + }, + pxtControlsForOfDef.url, + String(pxt.toolbox.getNamespaceColor('loops')) + ); + } + }; + + // controls_for_of + const controlsForOfId = "controls_for_of"; + const controlsForOfDef = pxt.blocks.getBlockDefinition(controlsForOfId); + Blockly.Blocks[controlsForOfId] = { + init: function () { + this.jsonInit({ + "message0": controlsForOfDef.block["message0"], + "args0": [ + { + "type": "field_variable", + "name": "VAR", + "variable": controlsForOfDef.block["variable"] + // Please note that most multilingual characters + // cannot be used as variable name at this point. + // Translate or decide the default variable name + // with care. + }, + { + "type": "input_value", + "name": "LIST", + "check": "Array" + } + ], + "previousStatement": null, + "nextStatement": null, + "colour": pxt.toolbox.blockColors['loops'], + "inputsInline": true + }); + + this.appendStatementInput('DO') + .appendField(controlsForOfDef.block["appendField"]); + + let thisBlock = this; + setHelpResources(this, + controlsForOfId, + controlsForOfDef.name, + function () { + return pxt.Util.rlf(controlsForOfDef.tooltip as string, thisBlock.getField('VAR').getText()); + }, + controlsForOfDef.url, + String(pxt.toolbox.getNamespaceColor('loops')) + ); + } + }; +} \ No newline at end of file diff --git a/newblocks/builtins/math.ts b/newblocks/builtins/math.ts new file mode 100644 index 000000000000..9089a4b4f465 --- /dev/null +++ b/newblocks/builtins/math.ts @@ -0,0 +1,296 @@ + +/// + +import * as Blockly from "blockly" +import { attachCardInfo, installBuiltinHelpInfo, installHelpResources, setBuiltinHelpInfo, setHelpResources } from "../help"; +import { provider } from "../constants"; + +export function initMath(blockInfo: pxtc.BlocksInfo) { + // math_op2 + const mathOp2Id = "math_op2"; + const mathOp2qName = "Math.min"; // TODO: implement logic so that this changes based on which is used (min or max) + const mathOp2Def = pxt.blocks.getBlockDefinition(mathOp2Id); + const mathOp2Tooltips = mathOp2Def.tooltip as pxt.Map; + Blockly.Blocks[mathOp2Id] = { + init: function () { + this.jsonInit({ + "message0": lf("%1 of %2 and %3"), + "args0": [ + { + "type": "field_dropdown", + "name": "op", + "options": [ + [lf("{id:op}min"), "min"], + [lf("{id:op}max"), "max"] + ] + }, + { + "type": "input_value", + "name": "x", + "check": "Number" + }, + { + "type": "input_value", + "name": "y", + "check": "Number" + } + ], + "inputsInline": true, + "output": "Number", + "outputShape": provider.SHAPES.ROUND, + "colour": pxt.toolbox.getNamespaceColor('math') + }); + + setHelpResources(this, + mathOp2Id, + mathOp2Def.name, + function (block: any) { + return mathOp2Tooltips[block.getFieldValue('op')]; + }, + mathOp2Def.url, + pxt.toolbox.getNamespaceColor(mathOp2Def.category) + ); + + }, + codeCard: attachCardInfo(blockInfo, mathOp2qName) + }; + + // math_op3 + const mathOp3Id = "math_op3"; + const mathOp3Def = pxt.blocks.getBlockDefinition(mathOp3Id); + const mathOp3qName = "Math.abs"; + Blockly.Blocks[mathOp3Id] = { + init: function () { + this.jsonInit({ + "message0": mathOp3Def.block["message0"], + "args0": [ + { + "type": "input_value", + "name": "x", + "check": "Number" + } + ], + "inputsInline": true, + "output": "Number", + "outputShape": provider.SHAPES.ROUND, + "colour": pxt.toolbox.getNamespaceColor('math') + }); + + setBuiltinHelpInfo(this, mathOp3Id); + }, + codeCard: attachCardInfo(blockInfo, mathOp3qName) + }; + + // builtin math_number, math_integer, math_whole_number, math_number_minmax + //XXX Integer validation needed. + const numberBlocks = ['math_number', 'math_integer', 'math_whole_number', 'math_number_minmax'] + numberBlocks.forEach(num_id => { + const mInfo = pxt.blocks.getBlockDefinition(num_id); + installHelpResources( + num_id, + mInfo.name, + mInfo.tooltip, + mInfo.url, + (Blockly as any).Colours.textField, + (Blockly as any).Colours.textField, + (Blockly as any).Colours.textField + ); + }) + + // builtin math_arithmetic + const msg = Blockly.Msg; + const mathArithmeticId = "math_arithmetic"; + const mathArithmeticDef = pxt.blocks.getBlockDefinition(mathArithmeticId); + const mathArithmeticTooltips = mathArithmeticDef.tooltip as pxt.Map; + msg.MATH_ADDITION_SYMBOL = mathArithmeticDef.block["MATH_ADDITION_SYMBOL"]; + msg.MATH_SUBTRACTION_SYMBOL = mathArithmeticDef.block["MATH_SUBTRACTION_SYMBOL"]; + msg.MATH_MULTIPLICATION_SYMBOL = mathArithmeticDef.block["MATH_MULTIPLICATION_SYMBOL"]; + msg.MATH_DIVISION_SYMBOL = mathArithmeticDef.block["MATH_DIVISION_SYMBOL"]; + msg.MATH_POWER_SYMBOL = mathArithmeticDef.block["MATH_POWER_SYMBOL"]; + + installHelpResources( + mathArithmeticId, + mathArithmeticDef.name, + function (block: any) { + return mathArithmeticTooltips[block.getFieldValue('OP')]; + }, + mathArithmeticDef.url, + pxt.toolbox.getNamespaceColor(mathArithmeticDef.category) + ); + + // builtin math_modulo + const mathModuloId = "math_modulo"; + const mathModuloDef = pxt.blocks.getBlockDefinition(mathModuloId); + msg.MATH_MODULO_TITLE = mathModuloDef.block["MATH_MODULO_TITLE"]; + installBuiltinHelpInfo(mathModuloId); + + initMathOpBlock(); + initMathRoundBlock(); +} + + + +export function initMathOpBlock() { + const allOperations = pxt.blocks.MATH_FUNCTIONS.unary.concat(pxt.blocks.MATH_FUNCTIONS.binary).concat(pxt.blocks.MATH_FUNCTIONS.infix); + const mathOpId = "math_js_op"; + const mathOpDef = pxt.blocks.getBlockDefinition(mathOpId); + Blockly.Blocks[mathOpId] = { + init: function () { + const b = this as Blockly.Block; + b.setPreviousStatement(false); + b.setNextStatement(false); + b.setOutput(true, "Number"); + b.setOutputShape(provider.SHAPES.ROUND); + b.setInputsInline(true); + + const ddi = b.appendDummyInput("op_dropdown") + ddi.appendField( + new Blockly.FieldDropdown(allOperations.map(op => [mathOpDef.block[op], op]), + (op: string) => onOperatorSelect(b, op)), + "OP"); + + addArgInput(b, false); + + // Because the shape of inputs changes, we need a mutation. Technically the op tells us + // how many inputs we should have but we can't read its value at init time + appendMutation(b, { + mutationToDom: mutation => { + let infix: boolean; + for (let i = 0; i < b.inputList.length; i++) { + const input = b.inputList[i]; + if (input.name === "op_dropdown") { + infix = false; + break; + } + else if (input.name === "ARG0") { + infix = true; + break; + } + } + mutation.setAttribute("op-type", (b.getInput("ARG1") ? (infix ? "infix" : "binary") : "unary").toString()); + return mutation; + }, + domToMutation: saved => { + if (saved.hasAttribute("op-type")) { + const type = saved.getAttribute("op-type"); + if (type != "unary") { + addArgInput(b, true); + } + changeInputOrder(b, type === "infix"); + } + } + }); + } + }; + + installHelpResources( + mathOpId, + mathOpDef.name, + function (block: Blockly.Block) { + return (mathOpDef.tooltip as pxt.Map)[block.getFieldValue("OP")]; + }, + mathOpDef.url, + pxt.toolbox.getNamespaceColor(mathOpDef.category) + ); + + function onOperatorSelect(b: Blockly.Block, op: string) { + if (isUnaryOp(op)) { + b.removeInput("ARG1", true); + } + else if (!b.getInput("ARG1")) { + addArgInput(b, true); + } + + changeInputOrder(b, isInfixOp(op)); + + return op; + } + + function addArgInput(b: Blockly.Block, second: boolean) { + const i = b.appendValueInput("ARG" + (second ? 1 : 0)); + i.setCheck("Number"); + if (second) { + (i.connection as any).setShadowDom(numberShadowDom()); + (i.connection as any).respawnShadow_(); + } + } + + function changeInputOrder(b: Blockly.Block, infix: boolean) { + let hasTwoArgs = !!b.getInput("ARG1"); + + if (infix) { + if (hasTwoArgs) { + b.moveInputBefore("op_dropdown", "ARG1") + } + b.moveInputBefore("ARG0", "op_dropdown"); + } + else { + if (hasTwoArgs) { + b.moveInputBefore("ARG0", "ARG1"); + } + b.moveInputBefore("op_dropdown", "ARG0"); + } + } +} + +function isUnaryOp(op: string) { + return pxt.blocks.MATH_FUNCTIONS.unary.indexOf(op) !== -1; +} + +function isInfixOp(op: string) { + return pxt.blocks.MATH_FUNCTIONS.infix.indexOf(op) !== -1; +} + +let cachedDom: Element; +function numberShadowDom() { + // 0 + if (!cachedDom) { + cachedDom = document.createElement("shadow") + cachedDom.setAttribute("type", "math_number"); + const field = document.createElement("field"); + field.setAttribute("name", "NUM"); + field.textContent = "0"; + cachedDom.appendChild(field); + } + return cachedDom; +} + + +export function initMathRoundBlock() { + const allOperations = pxt.blocks.ROUNDING_FUNCTIONS; + const mathRoundId = "math_js_round"; + const mathRoundDef = pxt.blocks.getBlockDefinition(mathRoundId); + Blockly.Blocks[mathRoundId] = { + init: function () { + const b = this as Blockly.Block; + b.setPreviousStatement(false); + b.setNextStatement(false); + b.setOutput(true, "Number"); + b.setOutputShape(provider.SHAPES.ROUND); + b.setInputsInline(true); + + const ddi = b.appendDummyInput("round_dropdown") + ddi.appendField( + new Blockly.FieldDropdown(allOperations.map(op => [mathRoundDef.block[op], op])), + "OP" + ); + + addArgInput(b); + } + }; + + installHelpResources( + mathRoundId, + mathRoundDef.name, + function (block: Blockly.Block) { + return (mathRoundDef.tooltip as pxt.Map)[block.getFieldValue("OP")]; + }, + mathRoundDef.url, + pxt.toolbox.getNamespaceColor(mathRoundDef.category) + ); + + function addArgInput(b: Blockly.Block) { + const i = b.appendValueInput("ARG0"); + i.setCheck("Number"); + } +} \ No newline at end of file diff --git a/newblocks/builtins/misc.ts b/newblocks/builtins/misc.ts new file mode 100644 index 000000000000..cd4c54da5c8b --- /dev/null +++ b/newblocks/builtins/misc.ts @@ -0,0 +1,184 @@ +import * as Blockly from "blockly"; +import { FieldTsExpression } from "../fields/field_tsexpression"; +import { GrayBlock, GrayBlockStatement } from "../loader"; +import { setHelpResources } from "../help"; + +export function initOnStart() { + // on_start + const onStartDef = pxt.blocks.getBlockDefinition(ts.pxtc.ON_START_TYPE); + Blockly.Blocks[ts.pxtc.ON_START_TYPE] = { + init: function () { + this.jsonInit({ + "message0": onStartDef.block["message0"], + "args0": [ + { + "type": "input_dummy" + }, + { + "type": "input_statement", + "name": "HANDLER" + } + ], + "colour": (pxt.appTarget.runtime ? pxt.appTarget.runtime.onStartColor : '') || pxt.toolbox.getNamespaceColor('loops') + }); + + setHelpResources(this, + ts.pxtc.ON_START_TYPE, + onStartDef.name, + onStartDef.tooltip, + onStartDef.url, + String((pxt.appTarget.runtime ? pxt.appTarget.runtime.onStartColor : '') || pxt.toolbox.getNamespaceColor('loops')), + undefined, undefined, + pxt.appTarget.runtime ? pxt.appTarget.runtime.onStartUnDeletable : false + ); + } + }; + + Blockly.Blocks[pxtc.TS_STATEMENT_TYPE] = { + init: function () { + let that: GrayBlockStatement = this; + that.setColour("#717171") + that.setPreviousStatement(true); + that.setNextStatement(true); + that.setInputsInline(false); + + let pythonMode: boolean; + let lines: string[]; + + that.domToMutation = (element: Element) => { + const n = parseInt(element.getAttribute("numlines")); + that.declaredVariables = element.getAttribute("declaredvars"); + + lines = []; + for (let i = 0; i < n; i++) { + const line = element.getAttribute("line" + i); + lines.push(line); + } + + // Add the initial TS inputs + that.setPythonEnabled(false); + }; + + that.mutationToDom = () => { + let mutation = document.createElement("mutation"); + + if (lines) { + lines.forEach((line, index) => mutation.setAttribute("line" + index, line)); + mutation.setAttribute("numlines", lines.length.toString()); + } + + if (that.declaredVariables) { + mutation.setAttribute("declaredvars", this.declaredVariables); + } + + return mutation; + }; + + // Consumed by the webapp + that.setPythonEnabled = (enabled: boolean) => { + if (pythonMode === enabled) return; + + // Remove all inputs + while (that.inputList.length) { + that.removeInput(that.inputList[0].name); + } + + pythonMode = enabled; + if (enabled) { + // This field must be named LINE0 because otherwise Blockly will crash + // when trying to make an insertion marker. All insertion marker blocks + // need to have the same fields as the real block, and this field will + // always be created by domToMutation regardless of TS or Python mode + that.appendDummyInput().appendField(pxt.Util.lf(""), "LINE0") + that.setTooltip(lf("A Python statement that could not be converted to blocks")); + } + else { + lines.forEach((line, index) => { + that.appendDummyInput().appendField(line, "LINE" + index); + }); + that.setTooltip(lf("A JavaScript statement that could not be converted to blocks")); + } + } + + // Consumed by BlocklyCompiler + that.getLines = () => lines; + + that.setEditable(false); + + setHelpResources(this, + pxtc.TS_STATEMENT_TYPE, + lf("JavaScript statement"), + lf("A JavaScript statement that could not be converted to blocks"), + '/blocks/javascript-blocks', + '#717171' + ); + } + }; + + Blockly.Blocks[pxtc.TS_OUTPUT_TYPE] = { + init: function () { + let that: GrayBlock = this; + that.setColour("#717171") + that.setPreviousStatement(false); + that.setNextStatement(false); + that.setOutput(true); + that.setEditable(false); + that.appendDummyInput().appendField(new FieldTsExpression(""), "EXPRESSION"); + + that.setPythonEnabled = (enabled: boolean) => { + (that.getField("EXPRESSION") as FieldTsExpression).setPythonEnabled(enabled); + + if (enabled) { + that.setTooltip(lf("A Python expression that could not be converted to blocks")); + } + else { + that.setTooltip(lf("A JavaScript expression that could not be converted to blocks")); + } + } + + setHelpResources(that, + pxtc.TS_OUTPUT_TYPE, + lf("JavaScript expression"), + lf("A JavaScript expression that could not be converted to blocks"), + '/blocks/javascript-blocks', + "#717171" + ); + } + }; + + if (pxt.appTarget.runtime && pxt.appTarget.runtime.pauseUntilBlock) { + const blockOptions = pxt.appTarget.runtime.pauseUntilBlock; + const blockDef = pxt.blocks.getBlockDefinition(ts.pxtc.PAUSE_UNTIL_TYPE); + Blockly.Blocks[pxtc.PAUSE_UNTIL_TYPE] = { + init: function () { + const color = blockOptions.color || pxt.toolbox.getNamespaceColor('loops'); + + this.jsonInit({ + "message0": blockDef.block["message0"], + "args0": [ + { + "type": "input_value", + "name": "PREDICATE", + "check": "Boolean" + } + ], + "inputsInline": true, + "previousStatement": null, + "nextStatement": null, + "colour": color + }); + + setHelpResources(this, + ts.pxtc.PAUSE_UNTIL_TYPE, + blockDef.name, + blockDef.tooltip, + blockDef.url, + color, + undefined/*colourSecondary*/, + undefined/*colourTertiary*/, + false/*undeletable*/ + ); + } + } + } +} \ No newline at end of file diff --git a/newblocks/builtins/text.ts b/newblocks/builtins/text.ts new file mode 100644 index 000000000000..2750ead0c92c --- /dev/null +++ b/newblocks/builtins/text.ts @@ -0,0 +1,43 @@ +import * as Blockly from "blockly"; +import { installHelpResources, installBuiltinHelpInfo } from "../help"; +import { provider } from "../constants"; + +export function initText() { + // builtin text + const textInfo = pxt.blocks.getBlockDefinition('text'); + installHelpResources('text', textInfo.name, textInfo.tooltip, textInfo.url, + (Blockly as any).Colours.textField, + (Blockly as any).Colours.textField, + (Blockly as any).Colours.textField); + + // builtin text_length66tyyy + const textLengthId = "text_length"; + const textLengthDef = pxt.blocks.getBlockDefinition(textLengthId); + Blockly.Msg.TEXT_LENGTH_TITLE = textLengthDef.block["TEXT_LENGTH_TITLE"]; + + // We have to override this block definition because the builtin block + // allows both Strings and Arrays in its input check and that confuses + // our Blockly compiler + let block = Blockly.Blocks[textLengthId]; + block.init = function () { + this.jsonInit({ + "message0": Blockly.Msg.TEXT_LENGTH_TITLE, + "args0": [ + { + "type": "input_value", + "name": "VALUE", + "check": ['String'] + } + ], + "output": 'Number', + "outputShape": provider.SHAPES.ROUND + }); + } + installBuiltinHelpInfo(textLengthId); + + // builtin text_join + const textJoinId = "text_join"; + const textJoinDef = pxt.blocks.getBlockDefinition(textJoinId); + Blockly.Msg.TEXT_JOIN_TITLE_CREATEWITH = textJoinDef.block["TEXT_JOIN_TITLE_CREATEWITH"]; + installBuiltinHelpInfo(textJoinId); +} \ No newline at end of file diff --git a/newblocks/builtins/variables.ts b/newblocks/builtins/variables.ts new file mode 100644 index 000000000000..1d80b5a07be5 --- /dev/null +++ b/newblocks/builtins/variables.ts @@ -0,0 +1,182 @@ +import * as Blockly from "blockly"; +import { createFlyoutGroupLabel, createFlyoutHeadingLabel, mkVariableFieldBlock } from "../toolbox"; +import { installBuiltinHelpInfo, setBuiltinHelpInfo } from "../help"; + +export function initVariables() { + // We only give types to "special" variables like enum members and we don't + // want those showing up in the variable dropdown so filter the variables + // that show up to only ones that have an empty type + (Blockly.FieldVariable.prototype as any).getVariableTypes_ = () => [""]; + + let varname = lf("{id:var}item"); + Blockly.Variables.flyoutCategory = function (workspace: Blockly.WorkspaceSvg) { + let xmlList: HTMLElement[] = []; + + if (!pxt.appTarget.appTheme.hideFlyoutHeadings) { + // Add the Heading label + let headingLabel = createFlyoutHeadingLabel(lf("Variables"), + pxt.toolbox.getNamespaceColor('variables'), + pxt.toolbox.getNamespaceIcon('variables')); + xmlList.push(headingLabel); + } + + let button = document.createElement('button') as HTMLElement; + button.setAttribute('text', lf("Make a Variable...")); + button.setAttribute('callbackKey', 'CREATE_VARIABLE'); + + workspace.registerButtonCallback('CREATE_VARIABLE', function (button) { + Blockly.Variables.createVariableButtonHandler(button.getTargetWorkspace()); + }); + + xmlList.push(button); + + let blockList = Blockly.Variables.flyoutCategoryBlocks(workspace) as HTMLElement[]; + xmlList = xmlList.concat(blockList); + return xmlList; + }; + Blockly.Variables.flyoutCategoryBlocks = function (workspace) { + let variableModelList = workspace.getVariablesOfType(''); + + let xmlList: HTMLElement[] = []; + if (variableModelList.length > 0) { + let mostRecentVariable = variableModelList[variableModelList.length - 1]; + variableModelList.sort(Blockly.VariableModel.compareByName); + // variables getters first + for (let i = 0; i < variableModelList.length; i++) { + const variable = variableModelList[i]; + if (Blockly.Blocks['variables_get']) { + + let block = mkVariableFieldBlock("variables_get", variable.getId(), variable.type, variable.name, false); + block.setAttribute("gap", "8"); + xmlList.push(block); + } + } + xmlList[xmlList.length - 1].setAttribute('gap', '24'); + + if (Blockly.Blocks['variables_change'] || Blockly.Blocks['variables_set']) { + xmlList.unshift(createFlyoutGroupLabel(lf("Your Variables"))); + } + + if (Blockly.Blocks['variables_change']) { + let gap = Blockly.Blocks['variables_get'] ? 20 : 8; + let block = mkVariableFieldBlock("variables_change", mostRecentVariable.getId(), mostRecentVariable.type, mostRecentVariable.name, false); + block.setAttribute("gap", gap + "") + { + let value = Blockly.utils.xml.createElement('value'); + value.setAttribute('name', 'VALUE'); + let shadow = Blockly.utils.xml.createElement('shadow'); + shadow.setAttribute("type", "math_number"); + value.appendChild(shadow); + let field = Blockly.utils.xml.createElement('field'); + field.setAttribute('name', 'NUM'); + field.appendChild(document.createTextNode("1")); + shadow.appendChild(field); + block.appendChild(value); + } + xmlList.unshift(block); + } + if (Blockly.Blocks['variables_set']) { + let gap = Blockly.Blocks['variables_change'] ? 8 : 24; + let block = mkVariableFieldBlock("variables_set", mostRecentVariable.getId(), mostRecentVariable.type, mostRecentVariable.name, false); + block.setAttribute("gap", gap + "") + { + let value = Blockly.utils.xml.createElement('value'); + value.setAttribute('name', 'VALUE'); + let shadow = Blockly.utils.xml.createElement('shadow'); + shadow.setAttribute("type", "math_number"); + value.appendChild(shadow); + let field = Blockly.utils.xml.createElement('field'); + field.setAttribute('name', 'NUM'); + field.appendChild(document.createTextNode("0")); + shadow.appendChild(field); + block.appendChild(value); + } + xmlList.unshift(block); + } + } + return xmlList; + }; + + // builtin variables_get + const msg = Blockly.Msg; + const variablesGetId = "variables_get"; + const variablesGetDef = pxt.blocks.getBlockDefinition(variablesGetId); + msg.VARIABLES_GET_CREATE_SET = variablesGetDef.block["VARIABLES_GET_CREATE_SET"]; + installBuiltinHelpInfo(variablesGetId); + + const variablesReporterGetId = "variables_get_reporter"; + installBuiltinHelpInfo(variablesReporterGetId); + + // Dropdown menu of variables_get + msg.RENAME_VARIABLE = lf("Rename variable..."); + msg.DELETE_VARIABLE = lf("Delete the \"%1\" variable"); + msg.DELETE_VARIABLE_CONFIRMATION = lf("Delete %1 uses of the \"%2\" variable?"); + msg.NEW_VARIABLE_DROPDOWN = lf("New variable..."); + + // builtin variables_set + const variablesSetId = "variables_set"; + const variablesSetDef = pxt.blocks.getBlockDefinition(variablesSetId); + msg.VARIABLES_SET = variablesSetDef.block["VARIABLES_SET"]; + msg.VARIABLES_DEFAULT_NAME = varname; + msg.VARIABLES_SET_CREATE_GET = lf("Create 'get %1'"); + installBuiltinHelpInfo(variablesSetId); + + // pxt variables_change + const variablesChangeId = "variables_change"; + const variablesChangeDef = pxt.blocks.getBlockDefinition(variablesChangeId); + Blockly.Blocks[variablesChangeId] = { + init: function () { + this.jsonInit({ + "message0": variablesChangeDef.block["message0"], + "args0": [ + { + "type": "field_variable", + "name": "VAR", + "variable": varname + }, + { + "type": "input_value", + "name": "VALUE", + "check": "Number" + } + ], + "inputsInline": true, + "previousStatement": null, + "nextStatement": null, + "colour": pxt.toolbox.getNamespaceColor('variables') + }); + + setBuiltinHelpInfo(this, variablesChangeId); + }, + /** + * Add menu option to create getter block for this variable + * @param {!Array} options List of menu options to add to. + * @this Blockly.Block + */ + customContextMenu: function (options: any[]) { + if (!(this.inDebugWorkspace())) { + let option: any = { + enabled: this.workspace.remainingCapacity() > 0 + }; + + let name = this.getField("VAR").getText(); + option.text = lf("Create 'get {0}'", name) + + let xmlField = Blockly.utils.xml.createElement('field'); + xmlField.textContent = name; + xmlField.setAttribute('name', 'VAR'); + let xmlBlock = Blockly.utils.xml.createElement('block'); + xmlBlock.setAttribute('type', "variables_get"); + xmlBlock.appendChild(xmlField) + option.callback = Blockly.ContextMenu.callbackFactory(this, xmlBlock); + options.push(option); + } + } + }; + + // New variable dialog + msg.NEW_VARIABLE_TITLE = lf("New variable name:"); + + // Rename variable dialog + msg.RENAME_VARIABLE_TITLE = lf("Rename all '%1' variables to:"); +} \ No newline at end of file diff --git a/newblocks/compiler/compiler.ts b/newblocks/compiler/compiler.ts new file mode 100644 index 000000000000..d41fcf6efd5c --- /dev/null +++ b/newblocks/compiler/compiler.ts @@ -0,0 +1,1333 @@ +/// + + +import * as Blockly from "blockly"; +import { BlockCompilationResult, BlockCompileOptions, BlockDeclarationType, BlockDiagnostic, Environment, GrayBlockStatement, StdFunc, VarInfo, mkEnv } from "./environment"; +import { IfBlock, attachPlaceholderIf, defaultValueForType, find, getConcreteType, getEscapedCBParameters, infer, isBooleanType, isFunctionRecursive, isStringType, lookup, returnType } from "./typeChecker"; +import { append, countOptionals, escapeVarName, forEachChildExpression, getInputTargetBlock, getLoopVariableField, isFunctionDefinition, isMutatingBlock, visibleParams } from "./util"; +import { isArrayType } from "../toolbox"; +import { MutatorTypes } from "../legacyMutations"; +import { trackAllVariables } from "./variables"; +import { FieldTilemap } from "../fields/field_tilemap"; +import { CommonFunctionBlock } from "../plugins/functions/commonFunctionMixin"; +import { FieldTextInput } from "../fields/field_textinput"; + + +interface Rect { + id: string; + x: number; + y: number; + width: number; + height: number; +} + +interface CommentMap { + orphans: Blockly.WorkspaceComment[]; + idToComments: pxt.Map; +} + + +export function compileBlockAsync(b: Blockly.Block, blockInfo: pxtc.BlocksInfo): Promise { + const w = b.workspace; + const e = mkEnv(w, blockInfo); + infer(w && w.getAllBlocks(false), e, w); + const compiled = compileStatementBlock(e, b) + e.placeholders = {}; + return tdASTtoTS(e, compiled); +} + +export function compileAsync(b: Blockly.Workspace, blockInfo: pxtc.BlocksInfo, opts: BlockCompileOptions = {}): Promise { + const e = mkEnv(b, blockInfo, opts); + const [nodes, diags] = compileWorkspace(e, b, blockInfo); + const result = tdASTtoTS(e, nodes, diags); + return result; +} + +function eventWeight(b: Blockly.Block, e: Environment) { + if (b.type === ts.pxtc.ON_START_TYPE) { + return 0; + } + const api = e.stdCallTable[b.type]; + const key = callKey(e, b); + const hash = 1 + ts.pxtc.Util.codalHash16(key); + if (api && api.attrs.afterOnStart) + return hash; + else + return -hash; +} + +function compileWorkspace(e: Environment, w: Blockly.Workspace, blockInfo: pxtc.BlocksInfo): [pxt.blocks.JsNode[], BlockDiagnostic[]] { + try { + // all compiled top level blocks are events + let allBlocks = w.getAllBlocks(false); + + if (pxt.react.getTilemapProject) { + pxt.react.getTilemapProject().removeInactiveBlockAssets(allBlocks.map(b => b.id)); + } + + // the top blocks are storted by blockly + let topblocks = w.getTopBlocks(true); + // reorder remaining events by names (top blocks still contains disabled blocks) + topblocks = topblocks.sort((a, b) => { + return eventWeight(a, e) - eventWeight(b, e) + }); + // update disable blocks + updateDisabledBlocks(e, allBlocks, topblocks); + // drop disabled blocks + allBlocks = allBlocks.filter(b => b.isEnabled()); + topblocks = topblocks.filter(b => b.isEnabled()); + trackAllVariables(topblocks, e); + infer(allBlocks, e, w); + + const stmtsMain: pxt.blocks.JsNode[] = []; + + // compile workspace comments, add them to the top + const topComments = w.getTopComments(true); + const commentMap = groupWorkspaceComments(topblocks as Blockly.BlockSvg[], + topComments as Blockly.WorkspaceCommentSvg[]); + + commentMap.orphans.forEach(comment => append(stmtsMain, compileWorkspaceComment(comment).children)); + + topblocks.forEach(b => { + if (commentMap.idToComments[b.id]) { + commentMap.idToComments[b.id].forEach(comment => { + append(stmtsMain, compileWorkspaceComment(comment).children); + }); + } + if (b.type == ts.pxtc.ON_START_TYPE) + append(stmtsMain, compileStatementBlock(e, b)); + else { + const compiled = pxt.blocks.mkBlock(compileStatementBlock(e, b)); + if (compiled.type == pxt.blocks.NT.Block) + append(stmtsMain, compiled.children); + else stmtsMain.push(compiled) + } + }); + + const stmtsEnums: pxt.blocks.JsNode[] = []; + e.enums.forEach(info => { + const models = w.getVariablesOfType(info.name); + if (models && models.length) { + const members: [string, number][] = models.map(m => { + const match = /^(\d+)([^0-9].*)$/.exec(m.name); + if (match) { + return [match[2], parseInt(match[1])] as [string, number]; + } + else { + // Someone has been messing with the XML... + return [m.name, -1] as [string, number]; + } + }); + + members.sort((a, b) => a[1] - b[1]); + + const nodes: pxt.blocks.JsNode[] = []; + let lastValue = -1; + members.forEach(([name, value], index) => { + let newNode: pxt.blocks.JsNode; + if (info.isBitMask) { + const shift = Math.log2(value); + if (shift >= 0 && Math.floor(shift) === shift) { + newNode = pxt.blocks.H.mkAssign(pxt.blocks.mkText(name), pxt.blocks.H.mkSimpleCall("<<", [pxt.blocks.H.mkNumberLiteral(1), pxt.blocks.H.mkNumberLiteral(shift)])); + } + } else if (info.isHash) { + const hash = ts.pxtc.Util.codalHash16(name.toLowerCase()); + newNode = pxt.blocks.H.mkAssign(pxt.blocks.mkText(name), pxt.blocks.H.mkNumberLiteral(hash)) + } + if (!newNode) { + if (value === lastValue + 1) { + newNode = pxt.blocks.mkText(name); + } + else { + newNode = pxt.blocks.H.mkAssign(pxt.blocks.mkText(name), pxt.blocks.H.mkNumberLiteral(value)); + } + } + nodes.push(newNode); + lastValue = value; + }); + const declarations = pxt.blocks.mkCommaSep(nodes, true); + declarations.glueToBlock = pxt.blocks.GlueMode.NoSpace; + stmtsEnums.push(pxt.blocks.mkGroup([ + pxt.blocks.mkText(`enum ${info.name}`), + pxt.blocks.mkBlock([declarations]) + ])); + } + }); + + e.kinds.forEach(info => { + const models = w.getVariablesOfType("KIND_" + info.name); + if (models && models.length) { + const userDefined = models.map(m => m.name).filter(n => info.initialMembers.indexOf(n) === -1); + + if (userDefined.length) { + stmtsEnums.push(pxt.blocks.mkGroup([ + pxt.blocks.mkText(`namespace ${info.name}`), + pxt.blocks.mkBlock(userDefined.map(varName => pxt.blocks.mkStmt(pxt.blocks.mkText(`export const ${varName} = ${info.name}.${info.createFunctionName}()`)))) + ])); + } + } + }); + + const leftoverVars = e.allVariables.filter(v => !v.alreadyDeclared).map(v => mkVariableDeclaration(v, blockInfo)); + + e.allVariables.filter(v => v.alreadyDeclared === BlockDeclarationType.Implicit && !v.isAssigned).forEach(v => { + const t = getConcreteType(v.type); + + // The primitive types all get initializers set to default values, other types are set to null + if (t.type === "string" || t.type === "number" || t.type === "boolean" || isArrayType(t.type)) return; + + e.diagnostics.push({ + blockId: v.firstReference && v.firstReference.id, + message: lf("Variable '{0}' is never assigned", v.name) + }); + }); + + return [stmtsEnums.concat(leftoverVars.concat(stmtsMain)), e.diagnostics]; + } catch (err) { + let be: Blockly.Block = (err as any).block; + if (be) { + be.setWarningText(err + ""); + e.errors.push(be); + } + else { + throw err; + } + } finally { + e.placeholders = {}; + } + + return [null, null] // unreachable +} + +export function callKey(e: Environment, b: Blockly.Block): string { + if (b.type == ts.pxtc.ON_START_TYPE) + return JSON.stringify({ name: ts.pxtc.ON_START_TYPE }); + else if (b.type == ts.pxtc.FUNCTION_DEFINITION_TYPE) + return JSON.stringify({ type: "function", name: b.getFieldValue("function_name") }); + + const key = JSON.stringify(blockKey(b)) + .replace(/"id"\s*:\s*"[^"]+"/g, ''); // remove blockly ids + + return key; +} + +function blockKey(b: Blockly.Block) { + const fields: string[] = [] + const inputs: any[] = [] + for (const input of b.inputList) { + for (const field of input.fieldRow) { + if (field.name) { + fields.push(field.getText()) + } + } + + if (input.type === Blockly.inputTypes.VALUE) { + if (input.connection.targetBlock()) { + inputs.push(blockKey(input.connection.targetBlock())); + } + else { + inputs.push(null); + } + } + } + + return { + type: b.type, + fields, + inputs + }; +} + +function setChildrenEnabled(block: Blockly.Block, enabled: boolean) { + block.setEnabled(enabled); + // propagate changes + const children = block.getDescendants(false); + for (const child of children) { + child.setEnabled(enabled); + } +} + +function updateDisabledBlocks(e: Environment, allBlocks: Blockly.Block[], topBlocks: Blockly.Block[]) { + // unset disabled + allBlocks.forEach(b => b.setEnabled(true)); + + // update top blocks + const events: pxt.Map = {}; + + function flagDuplicate(key: string, block: Blockly.Block) { + const otherEvent = events[key]; + if (otherEvent) { + // another block is already registered + setChildrenEnabled(block, false); + } else { + setChildrenEnabled(block, true); + events[key] = block; + } + } + + topBlocks.forEach(b => { + const call = e.stdCallTable[b.type]; + // multiple calls allowed + if (b.type == ts.pxtc.ON_START_TYPE) + flagDuplicate(ts.pxtc.ON_START_TYPE, b); + else if (isFunctionDefinition(b) || call && call.attrs.blockAllowMultiple && !call.attrs.handlerStatement) return; + // is this an event? + else if (call && call.hasHandler && !call.attrs.handlerStatement) { + // compute key that identifies event call + // detect if same event is registered already + const key = call.attrs.blockHandlerKey || callKey(e, b); + flagDuplicate(key, b); + } else { + // all non-events are disabled + let t = b; + while (t) { + setChildrenEnabled(b, false); + t = t.getNextBlock(); + } + } + }); +} + +function compileStatementBlock(e: Environment, b: Blockly.Block): pxt.blocks.JsNode[] { + let r: pxt.blocks.JsNode[]; + const comments: string[] = []; + e.stats[b.type] = (e.stats[b.type] || 0) + 1; + maybeAddComment(b, comments); + switch (b.type) { + case 'controls_if': + r = compileControlsIf(e, b as IfBlock, comments); + break; + case 'pxt_controls_for': + case 'controls_for': + case 'controls_simple_for': + r = compileControlsFor(e, b, comments); + break; + case 'pxt_controls_for_of': + case 'controls_for_of': + r = compileControlsForOf(e, b, comments); + break; + case 'variables_set': + r = [compileSet(e, b, comments)]; + break; + + case 'variables_change': + r = [compileChange(e, b, comments)]; + break; + + case 'controls_repeat_ext': + r = compileControlsRepeat(e, b, comments); + break; + + case 'device_while': + r = compileWhile(e, b, comments); + break; + case 'procedures_defnoreturn': + r = compileProcedure(e, b, comments); + break; + case 'function_definition': + r = compileFunctionDefinition(e, b, comments); + break + case 'procedures_callnoreturn': + r = [compileProcedureCall(e, b, comments)]; + break; + case 'function_call': + r = [compileFunctionCall(e, b, comments, true)]; + break; + case pxtc.TS_RETURN_STATEMENT_TYPE: + r = [compileReturnStatement(e, b, comments)]; + break; + case ts.pxtc.ON_START_TYPE: + r = compileStartEvent(e, b).children; + break; + case pxtc.TS_STATEMENT_TYPE: + r = compileTypescriptBlock(e, b); + break; + case pxtc.PAUSE_UNTIL_TYPE: + r = compilePauseUntilBlock(e, b, comments); + break; + case pxtc.TS_DEBUGGER_TYPE: + r = compileDebuggeStatementBlock(e, b); + break; + case pxtc.TS_BREAK_TYPE: + r = compileBreakStatementBlock(e, b); + break; + case pxtc.TS_CONTINUE_TYPE: + r = compileContinueStatementBlock(e, b); + break; + default: + let call = e.stdCallTable[b.type]; + if (call) r = [compileCall(e, b, comments)]; + else r = [pxt.blocks.mkStmt(compileExpression(e, b, comments))]; + break; + } + let l = r[r.length - 1]; if (l && !l.id) l.id = b.id; + + if (comments.length) { + addCommentNodes(comments, r) + } + + r.forEach(l => { + if ((l.type === pxt.blocks.NT.Block || l.type === pxt.blocks.NT.Prefix && pxt.Util.startsWith(l.op, "//")) && (b.type != pxtc.ON_START_TYPE || !l.id)) { + l.id = b.id + } + }); + + return r; +} + +// [t] is the expected type; we assume that we never null block children +// (because placeholder blocks have been inserted by the type-checking phase +// whenever a block was actually missing). +export function compileExpression(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode { + pxt.U.assert(b != null); + e.stats[b.type] = (e.stats[b.type] || 0) + 1; + maybeAddComment(b, comments); + let expr: pxt.blocks.JsNode; + if (b.type == "placeholder" || !(b.isEnabled && b.isEnabled())) { + const ret = find(returnType(e, b)); + if (ret.type === "Array") { + // FIXME: Can't use default type here because TS complains about + // the array having an implicit any type. However, forcing this + // to be a number array may cause type issues. Also, potential semicolon + // issues if we ever have a block where the array is not the first argument... + let isExpression = b.getParent().type === "lists_index_get"; + if (!isExpression) { + const call = e.stdCallTable[b.getParent().type]; + isExpression = call && call.isExpression; + } + const arrayNode = pxt.blocks.mkText("[0]"); + expr = isExpression ? arrayNode : prefixWithSemicolon(arrayNode); + } + else { + expr = defaultValueForType(returnType(e, b)); + } + } + else switch (b.type) { + case "math_number": + case "math_integer": + case "math_whole_number": + expr = compileNumber(e, b, comments); break; + case "math_number_minmax": + expr = compileNumber(e, b, comments); break; + case "math_op2": + expr = compileMathOp2(e, b, comments); break; + case "math_op3": + expr = compileMathOp3(e, b, comments); break; + case "math_arithmetic": + case "logic_compare": + case "logic_operation": + expr = compileArithmetic(e, b, comments); break; + case "math_modulo": + expr = compileModulo(e, b, comments); break; + case "logic_boolean": + expr = compileBoolean(e, b, comments); break; + case "logic_negate": + expr = compileNot(e, b, comments); break; + case "variables_get": + expr = compileVariableGet(e, b); break; + case "text": + expr = compileText(e, b, comments); break; + case "text_join": + expr = compileTextJoin(e, b, comments); break; + case "lists_create_with": + expr = compileCreateList(e, b, comments); break; + case "lists_index_get": + expr = compileListGet(e, b, comments); break; + case "lists_index_set": + expr = compileListSet(e, b, comments); break; + case "math_js_op": + case "math_js_round": + expr = compileMathJsOp(e, b, comments); break; + case pxtc.TS_OUTPUT_TYPE: + expr = extractTsExpression(e, b, comments); break; + case "argument_reporter_boolean": + case "argument_reporter_number": + case "argument_reporter_string": + case "argument_reporter_array": + case "argument_reporter_custom": + expr = compileArgumentReporter(e, b, comments); + break; + case "function_call_output": + expr = compileFunctionCall(e, b, comments, false); break; + default: + let call = e.stdCallTable[b.type]; + if (call) { + if (call.imageLiteral) + expr = compileImage(e, b, call.imageLiteral, call.imageLiteralColumns, call.imageLiteralRows, call.namespace, call.f, + visibleParams(call, countOptionals(b, call)).map(ar => compileArgument(e, b, ar, comments))) + else + expr = compileStdCall(e, b, call, comments); + } + else { + pxt.reportError("blocks", "unable to compile expression", { "details": b.type }); + expr = defaultValueForType(returnType(e, b)); + } + break; + } + + expr.id = b.id; + return expr; +} + +function compileStatements(e: Environment, b: Blockly.Block): pxt.blocks.JsNode { + let stmts: pxt.blocks.JsNode[] = []; + let firstBlock = b; + + while (b) { + if (b.isEnabled()) append(stmts, compileStatementBlock(e, b)); + b = b.getNextBlock(); + } + + if (firstBlock && e.blockDeclarations[firstBlock.id]) { + e.blockDeclarations[firstBlock.id].filter(v => !v.alreadyDeclared).forEach(varInfo => { + stmts.unshift(mkVariableDeclaration(varInfo, e.blocksInfo)); + varInfo.alreadyDeclared = BlockDeclarationType.Implicit; + }); + } + return pxt.blocks.mkBlock(stmts); +} + +function compileTypescriptBlock(e: Environment, b: Blockly.Block) { + return (b as GrayBlockStatement).getLines().map(line => pxt.blocks.mkText(line + "\n")); +} + +function compileDebuggeStatementBlock(e: Environment, b: Blockly.Block) { + if (b.getFieldValue("ON_OFF") == "1") { + return [ + pxt.blocks.mkText("debugger;\n") + ] + } + return []; +} + +function compileBreakStatementBlock(e: Environment, b: Blockly.Block) { + return [pxt.blocks.mkText("break;\n")] +} + +function compileContinueStatementBlock(e: Environment, b: Blockly.Block) { + return [pxt.blocks.mkText("continue;\n")] +} + +function prefixWithSemicolon(n: pxt.blocks.JsNode) { + const emptyStatement = pxt.blocks.mkStmt(pxt.blocks.mkText(";")); + emptyStatement.glueToBlock = pxt.blocks.GlueMode.NoSpace; + return pxt.blocks.mkGroup([emptyStatement, n]); +} + +function compilePauseUntilBlock(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode[] { + const options = pxt.appTarget.runtime && pxt.appTarget.runtime.pauseUntilBlock; + pxt.U.assert(!!options, "target has block enabled"); + + const ns = options.namespace; + const name = options.callName || "pauseUntil"; + const arg = compileArgument(e, b, { definitionName: "PREDICATE", actualName: "PREDICATE" }, comments); + const lambda = [pxt.blocks.mkGroup([pxt.blocks.mkText("() => "), arg])]; + + if (ns) { + return [pxt.blocks.mkStmt(pxt.blocks.H.namespaceCall(ns, name, lambda, false))]; + } + else { + return [pxt.blocks.mkStmt(pxt.blocks.H.mkCall(name, lambda, false, false))]; + } +} + +function compileControlsIf(e: Environment, b: IfBlock, comments: string[]): pxt.blocks.JsNode[] { + let stmts: pxt.blocks.JsNode[] = []; + // Notice the <= (if there's no else-if, we still compile the primary if). + for (let i = 0; i <= b.elseifCount_; ++i) { + let cond = compileExpression(e, getInputTargetBlock(e, b, "IF" + i), comments); + let thenBranch = compileStatements(e, getInputTargetBlock(e, b, "DO" + i)); + let startNode = pxt.blocks.mkText("if (") + if (i > 0) { + startNode = pxt.blocks.mkText("else if (") + startNode.glueToBlock = pxt.blocks.GlueMode.WithSpace; + } + append(stmts, [ + startNode, + cond, + pxt.blocks.mkText(")"), + thenBranch + ]) + } + if (b.elseCount_) { + let elseNode = pxt.blocks.mkText("else") + elseNode.glueToBlock = pxt.blocks.GlueMode.WithSpace; + append(stmts, [ + elseNode, + compileStatements(e, getInputTargetBlock(e, b, "ELSE")) + ]) + } + return stmts; +} + +function compileControlsFor(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode[] { + let bTo = getInputTargetBlock(e, b, "TO"); + let bDo = getInputTargetBlock(e, b, "DO"); + let bBy = getInputTargetBlock(e, b, "BY"); + let bFrom = getInputTargetBlock(e, b, "FROM"); + let incOne = !bBy || (bBy.type.match(/^math_number/) && extractNumber(bBy) == 1) + + let binding = lookup(e, b, getLoopVariableField(e, b).getField("VAR").getText()); + + return [ + pxt.blocks.mkText("for (let " + binding.escapedName + " = "), + bFrom ? compileExpression(e, bFrom, comments) : pxt.blocks.mkText("0"), + pxt.blocks.mkText("; "), + pxt.blocks.mkInfix(pxt.blocks.mkText(binding.escapedName), "<=", compileExpression(e, bTo, comments)), + pxt.blocks.mkText("; "), + incOne ? pxt.blocks.mkText(binding.escapedName + "++") : pxt.blocks.mkInfix(pxt.blocks.mkText(binding.escapedName), "+=", compileExpression(e, bBy, comments)), + pxt.blocks.mkText(")"), + compileStatements(e, bDo) + ] +} + +function compileControlsRepeat(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode[] { + let bound = compileExpression(e, getInputTargetBlock(e, b, "TIMES"), comments); + let body = compileStatements(e, getInputTargetBlock(e, b, "DO")); + let valid = (x: string) => !lookup(e, b, x); + + let name = "index"; + // Start at 2 because index0 and index1 are bad names + for (let i = 2; !valid(name); i++) + name = "index" + i; + return [ + pxt.blocks.mkText("for (let " + name + " = 0; "), + pxt.blocks.mkInfix(pxt.blocks.mkText(name), "<", bound), + pxt.blocks.mkText("; " + name + "++)"), + body + ] +} + +function compileWhile(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode[] { + let cond = compileExpression(e, getInputTargetBlock(e, b, "COND"), comments); + let body = compileStatements(e, getInputTargetBlock(e, b, "DO")); + return [ + pxt.blocks.mkText("while ("), + cond, + pxt.blocks.mkText(")"), + body + ] +} + +function compileControlsForOf(e: Environment, b: Blockly.Block, comments: string[]) { + let bOf = getInputTargetBlock(e, b, "LIST"); + let bDo = getInputTargetBlock(e, b, "DO"); + + let binding = lookup(e, b, getLoopVariableField(e, b).getField("VAR").getText()); + + return [ + pxt.blocks.mkText("for (let " + binding.escapedName + " of "), + compileExpression(e, bOf, comments), + pxt.blocks.mkText(")"), + compileStatements(e, bDo) + ] +} + +function compileVariableGet(e: Environment, b: Blockly.Block): pxt.blocks.JsNode { + const name = b.getField("VAR").getText(); + let binding = lookup(e, b, name); + if (!binding) // trying to compile a disabled block with a bogus variable + return pxt.blocks.mkText(name); + + if (!binding.firstReference) binding.firstReference = b; + + pxt.U.assert(binding != null && binding.type != null); + return pxt.blocks.mkText(binding.escapedName); +} + +function compileSet(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode { + let bExpr = getInputTargetBlock(e, b, "VALUE"); + let binding = lookup(e, b, b.getField("VAR").getText()); + + const currentScope = e.idToScope[b.id]; + let isDef = currentScope.declaredVars[binding.name] === binding && !binding.firstReference && !binding.alreadyDeclared; + + if (isDef) { + // Check the expression of the set block to determine if it references itself and needs + // to be hoisted + forEachChildExpression(b, child => { + if (child.type === "variables_get") { + let childBinding = lookup(e, child, child.getField("VAR").getText()); + if (childBinding === binding) isDef = false; + } + }, true); + } + + let expr = compileExpression(e, bExpr, comments); + + let bindString = binding.escapedName + " = "; + + binding.isAssigned = true; + + if (isDef) { + binding.alreadyDeclared = BlockDeclarationType.Assigned; + const declaredType = getConcreteType(binding.type); + + bindString = `let ${binding.escapedName} = `; + + if (declaredType) { + const expressionType = getConcreteType(returnType(e, bExpr)); + if (declaredType.type !== expressionType.type) { + bindString = `let ${binding.escapedName}: ${declaredType.type} = `; + } + } + } + else if (!binding.firstReference) { + binding.firstReference = b; + } + + return pxt.blocks.mkStmt( + pxt.blocks.mkText(bindString), + expr) +} + +function compileChange(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode { + let bExpr = getInputTargetBlock(e, b, "VALUE"); + let binding = lookup(e, b, b.getField("VAR").getText()); + let expr = compileExpression(e, bExpr, comments); + let ref = pxt.blocks.mkText(binding.escapedName); + return pxt.blocks.mkStmt(pxt.blocks.mkInfix(ref, "+=", expr)) +} + +function mkCallWithCallback(e: Environment, n: string, f: string, args: pxt.blocks.JsNode[], body: pxt.blocks.JsNode, argumentDeclaration?: pxt.blocks.JsNode, isExtension = false): pxt.blocks.JsNode { + body.noFinalNewline = true + let callback: pxt.blocks.JsNode; + if (argumentDeclaration) { + callback = pxt.blocks.mkGroup([argumentDeclaration, body]); + } + else { + callback = pxt.blocks.mkGroup([pxt.blocks.mkText("function ()"), body]); + } + + if (isExtension) + return pxt.blocks.mkStmt(pxt.blocks.H.extensionCall(f, args.concat([callback]), false)); + else if (n) + return pxt.blocks.mkStmt(pxt.blocks.H.namespaceCall(n, f, args.concat([callback]), false)); + else + return pxt.blocks.mkStmt(pxt.blocks.H.mkCall(f, args.concat([callback]), false)); +} + +function compileStartEvent(e: Environment, b: Blockly.Block): pxt.blocks.JsNode { + const bBody = getInputTargetBlock(e, b, "HANDLER"); + const body = compileStatements(e, bBody); + + if (pxt.appTarget.compile && pxt.appTarget.compile.onStartText && body && body.children) { + body.children.unshift(pxt.blocks.mkStmt(pxt.blocks.mkText(`// ${pxtc.ON_START_COMMENT}\n`))) + } + + return body; +} + +function compileEvent(e: Environment, b: Blockly.Block, stdfun: StdFunc, args: pxt.blocks.BlockParameter[], ns: string, comments: string[]): pxt.blocks.JsNode { + const compiledArgs: pxt.blocks.JsNode[] = args.map(arg => compileArgument(e, b, arg, comments)); + const bBody = getInputTargetBlock(e, b, "HANDLER"); + const body = compileStatements(e, bBody); + + if (pxt.appTarget.compile && pxt.appTarget.compile.emptyEventHandlerComments && body.children.length === 0) { + body.children.unshift(pxt.blocks.mkStmt(pxt.blocks.mkText(`// ${pxtc.HANDLER_COMMENT}`))) + } + + let argumentDeclaration: pxt.blocks.JsNode; + + if (isMutatingBlock(b) && b.mutation.getMutationType() === MutatorTypes.ObjectDestructuringMutator) { + argumentDeclaration = b.mutation.compileMutation(e, comments); + } + else if (stdfun.comp.handlerArgs.length) { + let handlerArgs = getEscapedCBParameters(b, stdfun, e); + argumentDeclaration = pxt.blocks.mkText(`function (${handlerArgs.join(", ")})`) + } + + return mkCallWithCallback(e, ns, stdfun.f, compiledArgs, body, argumentDeclaration, stdfun.isExtensionMethod); +} + +function compileImage(e: Environment, b: Blockly.Block, frames: number, columns: number, rows: number, n: string, f: string, args?: pxt.blocks.JsNode[]): pxt.blocks.JsNode { + args = args === undefined ? [] : args; + let state = "\n"; + rows = rows || 5; + columns = (columns || 5) * frames; + let leds = b.getFieldValue("LEDS"); + leds = leds.replace(/[ `\n]+/g, ''); + for (let i = 0; i < rows; ++i) { + for (let j = 0; j < columns; ++j) { + if (j > 0) + state += ' '; + state += (leds[(i * columns) + j] === '#') ? "#" : "."; + } + state += '\n'; + } + let lit = pxt.blocks.H.mkStringLiteral(state) + lit.canIndentInside = true + return pxt.blocks.H.namespaceCall(n, f, [lit].concat(args), false); +} + +function tdASTtoTS(env: Environment, app: pxt.blocks.JsNode[], diags?: BlockDiagnostic[]): Promise { + let res = pxt.blocks.flattenNode(app) + + // Note: the result of format is not used! + + return workerOpAsync("format", { format: { input: res.output, pos: 1 } }).then(() => { + return { + source: res.output, + sourceMap: res.sourceMap, + stats: env.stats, + diagnostics: diags || [] + }; + }) + +} + +function maybeAddComment(b: Blockly.Block, comments: string[]) { + // Check if getCommentText exists, block may be placeholder + const text = b.getCommentText?.(); + if (text) { + comments.push(text) + } +} + +function addCommentNodes(comments: string[], r: pxt.blocks.JsNode[]) { + const commentNodes: pxt.blocks.JsNode[] = [] + + for (const comment of comments) { + for (const line of comment.split("\n")) { + commentNodes.push(pxt.blocks.mkText(`// ${line}`)) + commentNodes.push(pxt.blocks.mkNewLine()) + } + } + + for (const commentNode of commentNodes.reverse()) { + r.unshift(commentNode) + } +} + +function mkVariableDeclaration(v: VarInfo, blockInfo: pxtc.BlocksInfo) { + const t = getConcreteType(v.type); + let defl: pxt.blocks.JsNode; + + if (t.type === "Array") { + defl = pxt.blocks.mkText("[]"); + } + else { + defl = defaultValueForType(t); + } + + let tp = "" + if (defl.op == "null" || defl.op == "[]") { + let tpname = t.type + // If the type is "Array" or null[] it means that we failed to narrow the type of array. + // Best we can do is just default to number[] + if (tpname === "Array" || tpname === "null[]") { + tpname = "number[]"; + } + let tpinfo = blockInfo.apis.byQName[tpname] + if (tpinfo && tpinfo.attributes.autoCreate) + defl = pxt.blocks.mkText(tpinfo.attributes.autoCreate + "()") + else + tp = ": " + tpname + } + return pxt.blocks.mkStmt(pxt.blocks.mkText("let " + v.escapedName + tp + " = "), defl) +} + +function groupWorkspaceComments(blocks: Blockly.BlockSvg[], comments: Blockly.WorkspaceCommentSvg[]): CommentMap { + if (!blocks.length || blocks.some(b => !b.rendered)) { + return { + orphans: comments, + idToComments: {} + }; + } + const blockBounds: Rect[] = blocks.map(block => { + const bounds = block.getBoundingRectangle(); + const size = block.getHeightWidth(); + return { + id: block.id, + x: bounds.left, + y: bounds.top, + width: size.width, + height: size.height + } + }); + + const map: CommentMap = { + orphans: [], + idToComments: {} + }; + + const radius = 20; + for (const comment of comments) { + const bounds = comment.getBoundingRectangle(); + const size = comment.getHeightWidth(); + + const x = bounds.left; + const y = bounds.top; + + let parent: Rect; + + for (const rect of blockBounds) { + if (doesIntersect(x, y, size.width, size.height, rect)) { + parent = rect; + } + else if (!parent && doesIntersect(x - radius, y - radius, size.width + radius * 2, size.height + radius * 2, rect)) { + parent = rect; + } + } + + if (parent) { + if (!map.idToComments[parent.id]) { + map.idToComments[parent.id] = []; + } + map.idToComments[parent.id].push(comment); + } + else { + map.orphans.push(comment); + } + } + + return map; +} + + +function doesIntersect(x: number, y: number, width: number, height: number, other: Rect) { + const xOverlap = between(x, other.x, other.x + other.width) || between(other.x, x, x + width); + const yOverlap = between(y, other.y, other.y + other.height) || between(other.y, y, y + height); + return xOverlap && yOverlap; + + function between(val: number, lower: number, upper: number) { + return val >= lower && val <= upper; + } +} + +function isComparisonOp(op: string) { + return ["LT", "LTE", "GT", "GTE", "EQ", "NEQ"].indexOf(op) !== -1; +} + +let opToTok: { [index: string]: string } = { + "ADD": "+", + "MINUS": "-", + "MULTIPLY": "*", + "DIVIDE": "/", + "LT": "<", + "LTE": "<=", + "GT": ">", + "GTE": ">=", + "AND": "&&", + "OR": "||", + "EQ": "==", + "NEQ": "!=", + "POWER": "**" +}; + +function compileArithmetic(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode { + let bOp = b.getFieldValue("OP"); + let left = getInputTargetBlock(e, b, "A"); + let right = getInputTargetBlock(e, b, "B"); + let args = [compileExpression(e, left, comments), compileExpression(e, right, comments)]; + + // Special handling for the case of comparing two literals (e.g. 0 === 5). TypeScript + // throws an error if we don't first cast to any + if (isComparisonOp(bOp) && isLiteral(e, left) && isLiteral(e, right)) { + if (pxt.blocks.flattenNode([args[0]]).output !== pxt.blocks.flattenNode([args[1]]).output) { + args = args.map(arg => + pxt.blocks.H.mkParenthesizedExpression( + pxt.blocks.mkGroup([arg, pxt.blocks.mkText(" as any")]) + ) + ); + } + } + + const t = returnType(e, left); + + if (isStringType(t)) { + if (bOp == "EQ") return pxt.blocks.H.mkSimpleCall("==", args); + else if (bOp == "NEQ") return pxt.blocks.H.mkSimpleCall("!=", args); + } else if (isBooleanType(t)) + return pxt.blocks.H.mkSimpleCall(opToTok[bOp], args); + + // Compilation of math operators. + pxt.U.assert(bOp in opToTok); + return pxt.blocks.H.mkSimpleCall(opToTok[bOp], args); +} + +function compileModulo(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode { + let left = getInputTargetBlock(e, b, "DIVIDEND"); + let right = getInputTargetBlock(e, b, "DIVISOR"); + let args = [compileExpression(e, left, comments), compileExpression(e, right, comments)]; + return pxt.blocks.H.mkSimpleCall("%", args); +} + +function compileMathOp2(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode { + let op = b.getFieldValue("op"); + let x = compileExpression(e, getInputTargetBlock(e, b, "x"), comments); + let y = compileExpression(e, getInputTargetBlock(e, b, "y"), comments); + return pxt.blocks.H.mathCall(op, [x, y]) +} + +function compileMathOp3(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode { + let x = compileExpression(e, getInputTargetBlock(e, b, "x"), comments); + return pxt.blocks.H.mathCall("abs", [x]); +} + +function compileText(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode { + return pxt.blocks.H.mkStringLiteral(b.getFieldValue("TEXT")); +} + +function compileTextJoin(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode { + let last: pxt.blocks.JsNode; + let i = 0; + while (true) { + const val = getInputTargetBlock(e, b, "ADD" + i); + i++; + + if (!val) { + if (i < b.inputList.length) { + continue; + } + else { + break; + } + } + + const compiled = compileExpression(e, val, comments); + if (!last) { + if (val.type.indexOf("text") === 0) { + last = compiled; + } + else { + // If we don't start with a string, then the TS won't match + // the implied semantics of the blocks + last = pxt.blocks.H.mkSimpleCall("+", [pxt.blocks.H.mkStringLiteral(""), compiled]); + } + } + else { + last = pxt.blocks.H.mkSimpleCall("+", [last, compiled]); + } + } + + if (!last) { + return pxt.blocks.H.mkStringLiteral(""); + } + + return last; +} + +function compileBoolean(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode { + return pxt.blocks.H.mkBooleanLiteral(b.getFieldValue("BOOL") == "TRUE"); +} + +function compileNot(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode { + let expr = compileExpression(e, getInputTargetBlock(e, b, "BOOL"), comments); + return pxt.blocks.mkPrefix("!", [pxt.blocks.H.mkParenthesizedExpression(expr)]); +} + +function compileCreateList(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode { + // collect argument + let args = b.inputList.map(input => input.connection && input.connection.targetBlock() ? compileExpression(e, input.connection.targetBlock(), comments) : undefined) + .filter(e => !!e); + + return pxt.blocks.H.mkArrayLiteral(args, !b.getInputsInline()); +} + +function compileListGet(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode { + const listBlock = getInputTargetBlock(e, b, "LIST"); + const listExpr = compileExpression(e, listBlock, comments); + const index = compileExpression(e, getInputTargetBlock(e, b, "INDEX"), comments); + const res = pxt.blocks.mkGroup([listExpr, pxt.blocks.mkText("["), index, pxt.blocks.mkText("]")]); + + return res; +} + +function compileListSet(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode { + const listBlock = getInputTargetBlock(e, b, "LIST"); + const listExpr = compileExpression(e, listBlock, comments); + const index = compileExpression(e, getInputTargetBlock(e, b, "INDEX"), comments); + const value = compileExpression(e, getInputTargetBlock(e, b, "VALUE"), comments); + const res = pxt.blocks.mkGroup([listExpr, pxt.blocks.mkText("["), index, pxt.blocks.mkText("] = "), value]); + + return listBlock.type === "lists_create_with" ? prefixWithSemicolon(res) : res; +} + +function compileMathJsOp(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode { + const op = b.getFieldValue("OP"); + const args = [compileExpression(e, getInputTargetBlock(e, b, "ARG0"), comments)]; + + if ((b as any).getInput("ARG1")) { + args.push(compileExpression(e, getInputTargetBlock(e, b, "ARG1"), comments)); + } + + return pxt.blocks.H.mathCall(op, args); +} + +function compileFunctionDefinition(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode[] { + const name = escapeVarName(b.getField("function_name").getText(), e, true); + const stmts = getInputTargetBlock(e, b, "STACK"); + const argsDeclaration = (b as CommonFunctionBlock).getArguments().map(a => { + if (a.type == "Array") { + const binding = lookup(e, b, a.name); + const declaredType = getConcreteType(binding.type); + const paramType = (declaredType?.type && declaredType.type !== "Array") ? declaredType.type : "any[]"; + return `${escapeVarName(a.name, e)}: ${paramType}`; + } + return `${escapeVarName(a.name, e)}: ${a.type}`; + }); + + const isRecursive = isFunctionRecursive(e, b, false); + return [ + pxt.blocks.mkText(`function ${name} (${argsDeclaration.join(", ")})${isRecursive ? ": any" : ""}`), + compileStatements(e, stmts) + ]; +} + +function compileProcedure(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode[] { + const name = escapeVarName(b.getFieldValue("NAME"), e, true); + const stmts = getInputTargetBlock(e, b, "STACK"); + return [ + pxt.blocks.mkText("function " + name + "() "), + compileStatements(e, stmts) + ]; +} + +function compileProcedureCall(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode { + const name = escapeVarName(b.getFieldValue("NAME"), e, true); + return pxt.blocks.mkStmt(pxt.blocks.mkText(name + "()")); +} + +function compileFunctionCall(e: Environment, b: Blockly.Block, comments: string[], statement: boolean): pxt.blocks.JsNode { + const name = escapeVarName(b.getField("function_name").getText(), e, true); + const externalInputs = !b.getInputsInline(); + const args: pxt.blocks.BlockParameter[] = (b as CommonFunctionBlock).getArguments().map(a => { + return { + actualName: a.name, + definitionName: a.id + }; + }); + + const compiledArgs = args.map(a => compileArgument(e, b, a, comments)); + const res = pxt.blocks.H.stdCall(name, compiledArgs, externalInputs) + + if (statement) { + return pxt.blocks.mkStmt(res); + } + return res; +} + +function compileReturnStatement(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode { + const expression = getInputTargetBlock(e, b, "RETURN_VALUE"); + + if (expression && expression.type != "placeholder") { + return pxt.blocks.mkStmt(pxt.blocks.mkText("return "), compileExpression(e, expression, comments)); + } + else { + return pxt.blocks.mkStmt(pxt.blocks.mkText("return")); + } +} + +function compileArgumentReporter(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode { + const name = escapeVarName(b.getFieldValue("VALUE"), e); + return pxt.blocks.mkText(name); +} + +function compileCall(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode { + const call = e.stdCallTable[b.type]; + if (call.imageLiteral) + return pxt.blocks.mkStmt(compileImage(e, b, call.imageLiteral, call.imageLiteralColumns, call.imageLiteralRows, call.namespace, call.f, visibleParams(call, countOptionals(b, call)).map(ar => compileArgument(e, b, ar, comments)))) + else if (call.hasHandler) + return compileEvent(e, b, call, eventArgs(call, b), call.namespace, comments) + else + return pxt.blocks.mkStmt(compileStdCall(e, b, call, comments)) +} + +function compileArgument(e: Environment, b: Blockly.Block, p: pxt.blocks.BlockParameter, comments: string[], beginningOfStatement = false): pxt.blocks.JsNode { + let f = b.getFieldValue(p.definitionName); + if (f != null) { + const field = b.getField(p.definitionName); + + if (field instanceof Blockly.FieldTextInput || field instanceof FieldTextInput) { + return pxt.blocks.H.mkStringLiteral(f); + } + else if (field instanceof FieldTilemap && !field.isGreyBlock) { + const project = pxt.react.getTilemapProject(); + const tmString = field.getValue(); + + if (tmString.startsWith("tilemap`")) { + return pxt.blocks.mkText(tmString); + } + + if (e.options.emitTilemapLiterals) { + try { + const data = pxt.sprite.decodeTilemap(tmString, "typescript", project); + if (data) { + const [ name ] = project.createNewTilemapFromData(data); + return pxt.blocks.mkText(`tilemap\`${name}\``); + } + } + catch (e) { + // This is a legacy tilemap or a grey block, ignore the exception + // and compile as a normal field + } + } + } + + // For some enums in pxt-minecraft, we emit the members as constants that are defined in + // libs/core. For example, Blocks.GoldBlock is emitted as GOLD_BLOCK + const type = e.blocksInfo.apis.byQName[p.type]; + if (type && type.attributes.emitAsConstant) { + for (const symbolName of Object.keys(e.blocksInfo.apis.byQName)) { + const symbol = e.blocksInfo.apis.byQName[symbolName]; + if (symbol && symbol.attributes && symbol.attributes.enumIdentity === f) { + return pxt.blocks.mkText(symbolName); + } + } + } + + let text = pxt.blocks.mkText(f) + text.canIndentInside = typeof f == "string" && f.indexOf('\n') >= 0; + return text; + } + else { + attachPlaceholderIf(e, b, p.definitionName); + const target = getInputTargetBlock(e, b, p.definitionName); + if (beginningOfStatement && target.type === "lists_create_with") { + // We have to be careful of array literals at the beginning of a statement + // because they can cause errors (i.e. they get parsed as an index). Add a + // semicolon to the previous statement just in case. + // FIXME: No need to do this if the previous statement was a code block + return prefixWithSemicolon(compileExpression(e, target, comments)); + } + + if (p.shadowOptions && p.shadowOptions.toString && isStringType(returnType(e, target))) { + return pxt.blocks.H.mkSimpleCall("+", [pxt.blocks.H.mkStringLiteral(""), pxt.blocks.H.mkParenthesizedExpression(compileExpression(e, target, comments))]); + } + + return compileExpression(e, target, comments) + } +} + +function compileStdCall(e: Environment, b: Blockly.Block, func: StdFunc, comments: string[]): pxt.blocks.JsNode { + let args: pxt.blocks.JsNode[] + if (isMutatingBlock(b) && b.mutation.getMutationType() === MutatorTypes.RestParameterMutator) { + args = b.mutation.compileMutation(e, comments).children; + } + else if (func.attrs.shim === "ENUM_GET") { + const enumName = func.attrs.enumName; + const enumMember = b.getFieldValue("MEMBER").replace(/^\d+/, ""); + return pxt.blocks.H.mkPropertyAccess(enumMember, pxt.blocks.mkText(enumName)); + } + else if (func.attrs.shim === "KIND_GET") { + const info = e.kinds.filter(k => k.blockId === func.attrs.blockId)[0]; + return pxt.blocks.H.mkPropertyAccess(b.getFieldValue("MEMBER"), pxt.blocks.mkText(info.name)); + } + else { + args = visibleParams(func, countOptionals(b, func)).map((p, i) => compileArgument(e, b, p, comments, func.isExtensionMethod && i === 0 && !func.isExpression)); + } + + let callNamespace = func.namespace; + let callName = func.f + if (func.attrs.blockAliasFor) { + const aliased = e.blocksInfo.apis.byQName[func.attrs.blockAliasFor]; + + if (aliased) { + callName = aliased.name; + callNamespace = aliased.namespace; + } + } + + const externalInputs = !b.getInputsInline(); + if (func.isIdentity) + return args[0]; + else if (func.property) { + return pxt.blocks.H.mkPropertyAccess(callName, args[0]); + } else if (callName == "@get@") { + return pxt.blocks.H.mkPropertyAccess(args[1].op.replace(/.*\./, ""), args[0]); + } else if (callName == "@set@") { + return pxt.blocks.H.mkAssign(pxt.blocks.H.mkPropertyAccess(args[1].op.replace(/.*\./, "").replace(/@set/, ""), args[0]), args[2]); + } else if (callName == "@change@") { + return pxt.blocks.H.mkSimpleCall("+=", [pxt.blocks.H.mkPropertyAccess(args[1].op.replace(/.*\./, "").replace(/@set/, ""), args[0]), args[2]]) + } else if (func.isExtensionMethod) { + if (func.attrs.defaultInstance) { + let instance: pxt.blocks.JsNode; + if (isMutatingBlock(b) && b.mutation.getMutationType() === MutatorTypes.DefaultInstanceMutator) { + instance = b.mutation.compileMutation(e, comments); + } + + if (instance) { + args.unshift(instance); + } + else { + args.unshift(pxt.blocks.mkText(func.attrs.defaultInstance)); + } + } + return pxt.blocks.H.extensionCall(callName, args, externalInputs); + } else if (callNamespace) { + return pxt.blocks.H.namespaceCall(callNamespace, callName, args, externalInputs); + } else { + return pxt.blocks.H.stdCall(callName, args, externalInputs); + } +} + +function compileWorkspaceComment(c: Blockly.WorkspaceComment): pxt.blocks.JsNode { + const content = c.getContent(); + return pxt.blocks.H.mkMultiComment(content.trim()); +} + +function isLiteral(e: Environment, b: Blockly.Block) { + return isNumericLiteral(e, b) || b.type === "logic_boolean" || b.type === "text"; +} + +function isNumericLiteral(e: Environment, b: Blockly.Block): boolean { + if (!b) return false; + + if (b.type === "math_number" || b.type === "math_integer" || b.type === "math_number_minmax" || b.type === "math_whole_number") { + return true; + } + + const blockInfo = e.stdCallTable[b.type]; + if (!blockInfo) return false; + + const { comp } = blockInfo; + + if (blockInfo.attrs.shim === "TD_ID" && comp.parameters.length === 1) { + const fieldValue = b.getFieldValue(comp.parameters[0].definitionName); + + if (fieldValue) { + return !isNaN(parseInt(fieldValue)) + } + else { + return isNumericLiteral(e, getInputTargetBlock(e, b, comp.parameters[0].definitionName)); + } + } + + return false; +} + +function extractNumber(b: Blockly.Block): number { + let v = b.getFieldValue(b.type === "math_number_minmax" ? "SLIDER" : "NUM"); + const parsed = parseFloat(v); + checkNumber(parsed, b); + return parsed; +} + +function checkNumber(n: number, b: Blockly.Block) { + if (!isFinite(n) || isNaN(n)) { + throwBlockError(lf("Number entered is either too large or too small"), b); + } +} + +function extractTsExpression(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode { + return pxt.blocks.mkText(b.getFieldValue("EXPRESSION").trim()); +} + +function compileNumber(e: Environment, b: Blockly.Block, comments: string[]): pxt.blocks.JsNode { + return pxt.blocks.H.mkNumberLiteral(extractNumber(b)); +} + +function throwBlockError(msg: string, block: Blockly.Block) { + let e = new Error(msg); + (e as any).block = block; + throw e; +} + +function eventArgs(call: StdFunc, b: Blockly.Block): pxt.blocks.BlockParameter[] { + return visibleParams(call, countOptionals(b, call)).filter(ar => !!ar.definitionName); +} + +export function workerOpAsync(op: string, arg: pxtc.service.OpArg) { + return pxt.worker.getWorker(pxt.webConfig.workerjs).opAsync(op, arg) +} \ No newline at end of file diff --git a/newblocks/compiler/environment.ts b/newblocks/compiler/environment.ts new file mode 100644 index 000000000000..27a67e1b6986 --- /dev/null +++ b/newblocks/compiler/environment.ts @@ -0,0 +1,223 @@ +/// + + +import * as Blockly from "blockly"; +import { escapeVarName, isFunctionDefinition } from "./util"; + +export interface Environment { + workspace: Blockly.Workspace; + options: BlockCompileOptions; + stdCallTable: pxt.Map; + userFunctionReturnValues: pxt.Map; + diagnostics: BlockDiagnostic[]; + errors: Blockly.Block[]; + renames: RenameMap; + stats: pxt.Map; + enums: pxtc.EnumInfo[]; + kinds: pxtc.KindInfo[]; + idToScope: pxt.Map; + blockDeclarations: pxt.Map; + blocksInfo: pxtc.BlocksInfo; + allVariables: VarInfo[]; + placeholders: pxt.Map>; +} + +// A description of each function from the "device library". Types are fetched +// from the Blockly blocks definition. +// - the key is the name of the Blockly.Block that we compile into a device call; +// - [f] is the TouchDevelop function name we compile to +// - [args] is a list of names; the name is taken to be either the name of a +// Blockly field value or, if not found, the name of a Blockly input block; if a +// field value is found, then this generates a string expression. If argument is a literal, simply emits the literal. +// - [isExtensionMethod] is a flag so that instead of generating a TouchDevelop +// call like [f(x, y...)], we generate the more "natural" [x → f (y...)] +// - [namespace] is also an optional flag to generate a "namespace" call, that +// is, "basic -> show image" instead of "micro:bit -> show image". +export interface StdFunc { + f: string; + comp: pxt.blocks.BlockCompileInfo; + attrs: ts.pxtc.CommentAttrs; + isExtensionMethod?: boolean; + isExpression?: boolean; + imageLiteral?: number; + imageLiteralColumns?: number; + imageLiteralRows?: number; + hasHandler?: boolean; + property?: boolean; + namespace?: string; + isIdentity?: boolean; // TD_ID shim +} + +export interface RenameMap { + oldToNew: pxt.Map; + takenNames: pxt.Map; + oldToNewFunctions: pxt.Map; +} + +export interface PlaceholderLikeBlock extends Blockly.Block { + p?: Point; +} + +export interface BlockCompilationResult { + source: string; + sourceMap: pxt.blocks.BlockSourceInterval[]; + stats: pxt.Map; + diagnostics: BlockDiagnostic[]; +} + +export interface BlockCompileOptions { + emitTilemapLiterals?: boolean; +} + + +export class Point { + constructor( + public link: Point, + public type: string, + public parentType?: Point, + public childType?: Point, + public isArrayType?: boolean + ) { } +} + +export interface Scope { + parent?: Scope; + firstStatement: Blockly.Block; + declaredVars: pxt.Map; + referencedVars: number[]; + assignedVars: number[]; + children: Scope[]; +} + +export enum BlockDeclarationType { + None = 0, + Argument, + Assigned, + Implicit +} + +export interface BlockDiagnostic { + blockId: string; + message: string; +} + +export interface VarInfo { + name: string; + id: number; + + escapedName?: string; + type?: Point; + alreadyDeclared?: BlockDeclarationType; + firstReference?: Blockly.Block; + isAssigned?: boolean; + isFunctionParameter?: boolean; +} + +export interface GrayBlock extends Blockly.Block { + setPythonEnabled(enabled: boolean): void; +} + +export interface GrayBlockStatement extends GrayBlock { + domToMutation(xmlElement: Element): void; + mutationToDom(): Element; + + getLines: () => string[]; + declaredVariables: string; +} + +export function emptyEnv(w: Blockly.Workspace, options: BlockCompileOptions): Environment { + return { + workspace: w, + options, + stdCallTable: {}, + userFunctionReturnValues: {}, + diagnostics: [], + errors: [], + renames: { + oldToNew: {}, + takenNames: {}, + oldToNewFunctions: {} + }, + stats: {}, + enums: [], + kinds: [], + idToScope: {}, + blockDeclarations: {}, + allVariables: [], + blocksInfo: null, + placeholders: {} + } +} + +// This function creates an empty environment where type inference has NOT yet +// been performed. +// - All variables have been assigned an initial [Point] in the union-find. +// - Variables have been marked to indicate if they are compatible with the +// TouchDevelop for-loop model. +export function mkEnv(w: Blockly.Workspace, blockInfo?: pxtc.BlocksInfo, options: BlockCompileOptions = {}): Environment { + // The to-be-returned environment. + let e = emptyEnv(w, options); + e.blocksInfo = blockInfo; + + // append functions in stdcalltable + if (blockInfo) { + // Enums, tagged templates, and namespaces are not enclosed in namespaces, + // so add them to the taken names to avoid collision + Object.keys(blockInfo.apis.byQName).forEach(name => { + const info = blockInfo.apis.byQName[name]; + // Note: the check for info.pkg filters out functions defined in the user's project. + // Otherwise, after the first compile the function will be renamed because it conflicts + // with itself. You can still get collisions if you attempt to define a function with + // the same name as a function defined in another file in the user's project (e.g. custom.ts) + if (info.pkg && (info.kind === pxtc.SymbolKind.Enum || info.kind === pxtc.SymbolKind.Function || info.kind === pxtc.SymbolKind.Module || info.kind === pxtc.SymbolKind.Variable)) { + e.renames.takenNames[info.qName] = true; + } + }); + + if (blockInfo.enumsByName) { + Object.keys(blockInfo.enumsByName).forEach(k => e.enums.push(blockInfo.enumsByName[k])); + } + + if (blockInfo.kindsByName) { + Object.keys(blockInfo.kindsByName).forEach(k => e.kinds.push(blockInfo.kindsByName[k])); + } + + blockInfo.blocks + .forEach(fn => { + if (e.stdCallTable[fn.attributes.blockId]) { + pxt.reportError("blocks", "function already defined", { + "details": fn.attributes.blockId, + "qualifiedName": fn.qName, + "packageName": fn.pkg, + }); + return; + } + e.renames.takenNames[fn.namespace] = true; + const comp = pxt.blocks.compileInfo(fn); + const instance = !!comp.thisParameter; + + e.stdCallTable[fn.attributes.blockId] = { + namespace: fn.namespace, + f: fn.name, + comp, + attrs: fn.attributes, + isExtensionMethod: instance, + isExpression: fn.retType && fn.retType !== "void", + imageLiteral: fn.attributes.imageLiteral || fn.attributes.gridLiteral, + imageLiteralColumns: fn.attributes.imageLiteralColumns, + imageLiteralRows: fn.attributes.imageLiteralRows, + hasHandler: pxt.blocks.hasHandler(fn), + property: !fn.parameters, + isIdentity: fn.attributes.shim == "TD_ID" + } + }); + + w.getTopBlocks(false).filter(isFunctionDefinition).forEach(b => { + // Add functions to the rename map to prevent name collisions with variables + const name = b.type === "procedures_defnoreturn" ? b.getFieldValue("NAME") : b.getField("function_name").getText(); + escapeVarName(name, e, true); + }); + } + + return e; +} \ No newline at end of file diff --git a/newblocks/compiler/typeChecker.ts b/newblocks/compiler/typeChecker.ts new file mode 100644 index 000000000000..6f63b43d697b --- /dev/null +++ b/newblocks/compiler/typeChecker.ts @@ -0,0 +1,746 @@ +import * as Blockly from "blockly"; + +import { Point, Environment, VarInfo, Scope, PlaceholderLikeBlock, StdFunc } from "./environment"; +import { countOptionals, getFunctionName, getInputTargetBlock, getLoopVariableField, isMutatingBlock, visibleParams } from "./util"; +import { getDefinition } from "../plugins/functions"; +import { CommonFunctionBlock } from "../plugins/functions/commonFunctionMixin"; + +interface DeclaredVariable { + name: string; + type: Point; + isFunctionParameter?: boolean; +} + +// FIXME: This type isn't exported by Blockly but duplicating it here isn't great... +export interface IfBlock extends Blockly.Block { + elseifCount_: number; + elseCount_: number; +} + +export function infer(allBlocks: Blockly.Block[], e: Environment, w: Blockly.Workspace) { + if (allBlocks) allBlocks.filter(b => b.isEnabled()).forEach((b: Blockly.Block) => { + try { + switch (b.type) { + case "math_op2": + unionParam(e, b, "x", ground(pNumber.type)); + unionParam(e, b, "y", ground(pNumber.type)); + break; + + case "math_op3": + unionParam(e, b, "x", ground(pNumber.type)); + break; + + case "math_arithmetic": + case "logic_compare": + switch (b.getFieldValue("OP")) { + case "ADD": case "MINUS": case "MULTIPLY": case "DIVIDE": + case "LT": case "LTE": case "GT": case "GTE": case "POWER": + unionParam(e, b, "A", ground(pNumber.type)); + unionParam(e, b, "B", ground(pNumber.type)); + break; + case "AND": case "OR": + attachPlaceholderIf(e, b, "A", pBoolean.type); + attachPlaceholderIf(e, b, "B", pBoolean.type); + break; + case "EQ": case "NEQ": + attachPlaceholderIf(e, b, "A"); + attachPlaceholderIf(e, b, "B"); + let p1 = returnType(e, getInputTargetBlock(e, b, "A")); + let p2 = returnType(e, getInputTargetBlock(e, b, "B")); + try { + union(p1, p2); + } catch (e) { + // TypeScript should catch this error and bubble it up + } + break; + } + break; + + case "logic_operation": + attachPlaceholderIf(e, b, "A", pBoolean.type); + attachPlaceholderIf(e, b, "B", pBoolean.type); + break; + + case "logic_negate": + attachPlaceholderIf(e, b, "BOOL", pBoolean.type); + break; + + case "controls_if": + for (let i = 0; i <= (b as IfBlock).elseifCount_; ++i) + attachPlaceholderIf(e, b, "IF" + i, pBoolean.type); + break; + + case "pxt_controls_for": + case "controls_simple_for": + unionParam(e, b, "TO", ground(pNumber.type)); + break; + case "pxt_controls_for_of": + case "controls_for_of": + const listTp = returnType(e, getInputTargetBlock(e, b, "LIST")); + const elementTp = lookup(e, b, getLoopVariableField(e, b).getField("VAR").getText()).type; + genericLink(listTp, elementTp); + break; + case "variables_set": + case "variables_change": + let p1 = lookup(e, b, b.getField("VAR").getText()).type; + attachPlaceholderIf(e, b, "VALUE"); + let rhs = getInputTargetBlock(e, b, "VALUE"); + if (rhs) { + // Get the inheritance chain for this type and check to see if the existing + // type shows up in it somewhere + let tr = returnTypeWithInheritance(e, rhs); + const t1 = find(p1); + if (t1.type && tr.slice(1).some(p => p.type === t1.type)) { + // If it does, we want to take the most narrow type (which will always be in 0) + p1.link = find(tr[0]); + } + else { + try { + union(p1, tr[0]); + } catch (e) { + // TypeScript should catch this error and bubble it up + } + } + } + break; + case "controls_repeat_ext": + unionParam(e, b, "TIMES", ground(pNumber.type)); + break; + + case "device_while": + attachPlaceholderIf(e, b, "COND", pBoolean.type); + break; + case "lists_index_get": + unionParam(e, b, "LIST", ground("Array")); + unionParam(e, b, "INDEX", ground(pNumber.type)); + const listType = returnType(e, getInputTargetBlock(e, b, "LIST")); + const ret = returnType(e, b); + genericLink(listType, ret); + break; + case "lists_index_set": + unionParam(e, b, "LIST", ground("Array")); + attachPlaceholderIf(e, b, "VALUE"); + handleGenericType(b, "LIST"); + unionParam(e, b, "INDEX", ground(pNumber.type)); + break; + case 'function_definition': + getReturnTypeOfFunction(e, b.getField("function_name",).getText()); + break; + case 'function_call': + case 'function_call_output': + (b as CommonFunctionBlock).getArguments().forEach(arg => { + unionParam(e, b, arg.id, ground(arg.type)); + }); + break; + case pxtc.TS_RETURN_STATEMENT_TYPE: + attachPlaceholderIf(e, b, "RETURN_VALUE"); + break; + case pxtc.PAUSE_UNTIL_TYPE: + unionParam(e, b, "PREDICATE", pBoolean); + break; + default: + if (b.type in e.stdCallTable) { + const call = e.stdCallTable[b.type]; + if (call.attrs.shim === "ENUM_GET" || call.attrs.shim === "KIND_GET") return; + visibleParams(call, countOptionals(b, call)).forEach((p, i) => { + const isInstance = call.isExtensionMethod && i === 0; + if (p.definitionName && !b.getFieldValue(p.definitionName)) { + let i = b.inputList.find((i: Blockly.Input) => i.name == p.definitionName); + const check = i?.connection?.getCheck(); + if (check) { + if (isInstance && connectionCheck(i) === "Array") { + let gen = handleGenericType(b, p.definitionName); + if (gen) { + return; + } + } + + // All of our injected blocks have single output checks, but the builtin + // blockly ones like string.length and array.length might have multiple + for (let j = 0; j < check.length; j++) { + try { + let t = check[j]; + unionParam(e, b, p.definitionName, ground(t)); + break; + } + catch (e) { + // Ignore type checking errors in the blocks... + } + } + } + } + }); + } + } + } catch (err) { + const be = ((err).block as Blockly.Block) || b; + be.setWarningText(err + ""); + e.errors.push(be); + } + }); + + // Last pass: if some variable has no type (because it was never used or + // assigned to), just unify it with int... + e.allVariables.forEach((v: VarInfo) => { + if (getConcreteType(v.type).type == null) { + if (!v.isFunctionParameter) { + union(v.type, ground(v.type.isArrayType ? "number[]" : pNumber.type)); + } + else if (v.type.isArrayType) { + v.type.type = "any[]" + } + } + }); + + function connectionCheck(i: Blockly.Input) { + const check = i.connection?.getCheck()?.[0]; + return i.name ? (check ? check : "T") : undefined; + } + + function handleGenericType(b: Blockly.Block, name: string) { + let genericArgs = b.inputList.filter((input: Blockly.Input) => connectionCheck(input) === "T"); + if (genericArgs.length) { + const gen = getInputTargetBlock(e, b, genericArgs[0].name); + if (gen) { + const arg = returnType(e, gen); + const arrayType = arg.type ? ground(returnType(e, gen).type + "[]") : ground(null); + genericLink(arrayType, arg); + unionParam(e, b, name, arrayType); + return true; + } + } + return false; + } +} + + +function union(p1: Point, p2: Point) { + let _p1 = find(p1); + let _p2 = find(p2); + assert(_p1.link == null && _p2.link == null); + + if (_p1 == _p2) { + return; + } + else if (isPrimitiveType(_p1)) { + unify(p1.type, p2.type); + return; + } + else if (isPrimitiveType(_p2)) { + unify(p1.type, p2.type); + + p1.type = null; + p1.link = _p2; + _p1.link = _p2; + _p1.isArrayType = _p2.isArrayType; + return; + } + else if (_p1.childType && _p2.childType) { + const ct = _p1.childType; + _p1.childType = null; + union(ct, _p2.childType); + } + else if (_p1.childType && !_p2.childType) { + _p2.childType = _p1.childType; + } + + if (_p1.parentType && _p2.parentType) { + const pt = _p1.parentType; + _p1.parentType = null; + union(pt, _p2.parentType); + } + else if (_p1.parentType && !_p2.parentType && !_p2.type) { + _p2.parentType = _p1.parentType; + } + + + let t = unify(_p1.type, _p2.type); + + p1.link = _p2; + _p1.link = _p2; + _p1.isArrayType = _p2.isArrayType; + p1.type = null; + p2.type = t; +} + +// Unify the *return* type of the parameter [n] of block [b] with point [p]. +function unionParam(e: Environment, b: Blockly.Block, n: string, p: Point) { + attachPlaceholderIf(e, b, n); + try { + union(returnType(e, getInputTargetBlock(e, b, n)), p); + } catch (e) { + // TypeScript should catch this error and bubble it up + } +} + +// Ground types. +export function mkPoint(t: string, isArrayType = false): Point { + return new Point(null, t, null, null, isArrayType); +} + +const pNumber = mkPoint("number"); +const pBoolean = mkPoint("boolean"); +const pString = mkPoint("string"); +const pUnit = mkPoint("void"); + +function ground(t?: string): Point { + if (!t) return mkPoint(t); + switch (t.toLowerCase()) { + case "number": return pNumber; + case "boolean": return pBoolean; + case "string": return pString; + case "void": return pUnit; + default: + // Unification variable. + return mkPoint(t); + } +} + +export function find(p: Point): Point { + if (p.link) + return find(p.link); + return p; +} + +function isPrimitiveType(point: Point) { + return point === pNumber || point === pBoolean || point === pString || point === pUnit; +} + +/////////////////////////////////////////////////////////////////////////////// +// Type inference +// +// Expressions are now directly compiled as a tree. This requires knowing, for +// each property ref, the right value for its [parent] property. +/////////////////////////////////////////////////////////////////////////////// + +// Infers the expected type of an expression by looking at the untranslated +// block and figuring out, from the look of it, what type of expression it +// holds. +export function returnType(e: Environment, b: Blockly.Block): Point { + assert(b != null); + + if (isPlaceholderBlock(b)) { + if (!b.p) b.p = mkPoint(null); + return find(b.p); + } + + if (b.type == "variables_get") + return find(lookup(e, b, b.getField("VAR").getText()).type); + + if (b.type == "function_call_output") { + return getReturnTypeOfFunctionCall(e, b); + } + + if (!b.outputConnection) { + return ground(pUnit.type); + } + + const check = b.outputConnection?.getCheck()?.[0] || "T"; + + if (check === "Array") { + const fullCheck = b.outputConnection.getCheck(); + if (fullCheck.length > 1) { + // HACK: The real type is stored as the second check + return ground(fullCheck[1]) + } + // lists_create_with and argument_reporter_array both hit this. + // For lists_create_with, we can safely infer the type from the + // first input that has a return type. + // For argument_reporter_array just return any[] for now + let tp: Point; + if (b.type == "lists_create_with") { + if (b.inputList && b.inputList.length) { + for (const input of b.inputList) { + if (input.connection && input.connection.targetBlock()) { + let t = find(returnType(e, input.connection.targetBlock())) + if (t) { + if (t.parentType) { + return t.parentType; + } + tp = t.type ? ground(t.type + "[]") : mkPoint(null); + genericLink(tp, t); + break; + } + } + } + } + } else if (b.type == "argument_reporter_array") { + if (!tp) { + tp = lookup(e, b, b.getFieldValue("VALUE")).type + } + } + + if (tp) tp.isArrayType = true; + return tp || mkPoint(null, true); + } + else if (check === "T") { + const func = e.stdCallTable[b.type]; + const isArrayGet = b.type === "lists_index_get"; + if (isArrayGet || func && func.comp.thisParameter) { + let parentInput: Blockly.Input; + + if (isArrayGet) { + parentInput = b.inputList.find(i => i.name === "LIST"); + } + else { + parentInput = b.inputList.find(i => i.name === func.comp.thisParameter.definitionName); + } + + if (parentInput.connection && parentInput.connection.targetBlock()) { + const parentType = returnType(e, parentInput.connection.targetBlock()); + if (parentType.childType) { + return parentType.childType; + } + const p = isArrayType(parentType.type) && parentType.type !== "Array" ? mkPoint(parentType.type.substr(0, parentType.type.length - 2)) : mkPoint(null); + genericLink(parentType, p); + return p; + } + } + return mkPoint(null); + } + + return ground(check); +} + +function returnTypeWithInheritance(e: Environment, b: Blockly.Block) { + const check = b.outputConnection?.getCheck(); + if (!check?.length || check[0] === "Array" || check[0] === "T") { + return [returnType(e, b)]; + } + + return check.map(t => ground(t)) +} + +function getReturnTypeOfFunction(e: Environment, name: string) { + if (!e.userFunctionReturnValues[name]) { + const definition = getDefinition(name, e.workspace); + + let res = mkPoint("void"); + + if (isFunctionRecursive(e, definition, true)) { + res = mkPoint("any"); + } + else { + const returnTypes: Point[] = []; + for (const child of definition.getDescendants(false)) { + if (child.type === "function_return") { + attachPlaceholderIf(e, child, "RETURN_VALUE"); + returnTypes.push(returnType(e, getInputTargetBlock(e, child, "RETURN_VALUE"))); + } + } + + if (returnTypes.length) { + try { + const unified = mkPoint(null); + for (const point of returnTypes) { + union(unified, point); + } + res = unified + } + catch (err) { + e.diagnostics.push({ + blockId: definition.id, + message: pxt.Util.lf("Function '{0}' has an invalid return type", name) + }); + + res = mkPoint("any") + } + } + } + + e.userFunctionReturnValues[name] = res; + } + + return e.userFunctionReturnValues[name]; +} + +function getReturnTypeOfFunctionCall(e: Environment, call: Blockly.Block) { + const name = call.getField("function_name").getText(); + return getReturnTypeOfFunction(e, name); +} + +// Basic type unification routine; easy, because there's no structural types. +// FIXME: Generics are not supported +function unify(t1: string, t2: string) { + if (t1 == null || t1 === "Array" && isArrayType(t2)) + return t2; + else if (t2 == null || t2 === "Array" && isArrayType(t1)) + return t1; + else if (t1 == t2) + return t1; + else + throw new Error("cannot mix " + t1 + " with " + t2); +} + +function isArrayType(type: string) { + return type && (type.indexOf("[]") !== -1 || type == "Array"); +} + +function mkPlaceholderBlock(e: Environment, parent: Blockly.Block, type?: string): PlaceholderLikeBlock { + // XXX define a proper placeholder block type + return { + type: "placeholder", + p: mkPoint(type || null), + workspace: e.workspace, + parentBlock_: parent + } as any; +} + +export function attachPlaceholderIf(e: Environment, b: Blockly.Block, n: string, type?: string) { + // Ugly hack to keep track of the type we want there. + const target = b.getInputTargetBlock(n); + if (!target) { + if (!e.placeholders[b.id]) { + e.placeholders[b.id] = {}; + } + + if (!e.placeholders[b.id][n]) { + e.placeholders[b.id][n] = mkPlaceholderBlock(e, b, type); + } + } + else if (target.type === pxtc.TS_OUTPUT_TYPE && !((target as any).p)) { + (target as any).p = mkPoint(null); + } +} + +function genericLink(parent: Point, child: Point) { + const p = find(parent); + const c = find(child); + if (p.childType) { + union(p.childType, c); + } + else if (!p.type) { + p.childType = c; + } + + if (c.parentType) { + union(c.parentType, p); + } + else if (!c.type) { + c.parentType = p; + } + + if (isArrayType(p.type)) + p.isArrayType = true; +} + +export function getConcreteType(point: Point, found: Point[] = []) { + const t = find(point) + if (found.indexOf(t) === -1) { + found.push(t); + if (!t.type || t.type === "Array") { + if (t.parentType) { + const parent = getConcreteType(t.parentType, found); + if (parent.type && parent.type !== "Array") { + if (isArrayType(parent.type)) { + t.type = parent.type.substr(0, parent.type.length - 2); + } else { + t.type = parent.type; + } + return t; + } + } + + if (t.childType) { + const child = getConcreteType(t.childType, found); + if (child.type) { + t.type = child.type + "[]"; + return t; + } + + } + } + } + return t; +} + +export function lookup(e: Environment, b: Blockly.Block, name: string): VarInfo { + return getVarInfo(name, e.idToScope[b.id]); +} + +function getVarInfo(name: string, scope: Scope): VarInfo { + if (scope && scope.declaredVars[name]) { + return scope.declaredVars[name]; + } + else if (scope && scope.parent) { + return getVarInfo(name, scope.parent); + } + else { + return null; + } +} + +export function getDeclaredVariables(block: Blockly.Block, e: Environment): DeclaredVariable[] { + switch (block.type) { + case 'pxt_controls_for': + case 'controls_simple_for': + return [{ + name: getLoopVariableField(e, block).getField("VAR").getText(), + type: pNumber + }]; + case 'pxt_controls_for_of': + case 'controls_for_of': + return [{ + name: getLoopVariableField(e, block).getField("VAR").getText(), + type: mkPoint(null) + }]; + case 'function_definition': + return (block as CommonFunctionBlock).getArguments().filter(arg => arg.type === "Array") + .map(arg => { + const point = mkPoint(null); + point.isArrayType = true; + return { + name: arg.name, + type: point, + isFunctionParameter: true + } + }); + default: + break; + } + + if (isMutatingBlock(block)) { + const declarations = block.mutation.getDeclaredVariables(); + if (declarations) { + return Object.keys(declarations).map(varName => ({ + name: varName, + type: mkPoint(declarations[varName]) + })); + } + } + + let stdFunc = e.stdCallTable[block.type]; + if (stdFunc && stdFunc.comp.handlerArgs.length) { + return getCBParameters(block, stdFunc, e); + } + + return []; +} + +// @param strict - if true, only return true if there is a return statement +// somewhere in the call graph that returns a call to this function. If false, +// return true if the function is called as an expression anywhere in the call +// graph +export function isFunctionRecursive(e: Environment, b: Blockly.Block, strict: boolean) { + const functionName = getFunctionName(b) + const visited: pxt.Map = {}; + + return checkForCallRecursive(b); + + function checkForCallRecursive(functionDefinition: Blockly.Block) { + let calls: Blockly.Block[]; + + if (strict) { + calls = functionDefinition.getDescendants(false) + .filter(child => child.type == "function_return") + .map(returnStatement => getInputTargetBlock(e, returnStatement, "RETURN_VALUE")) + .filter(returnValue => returnValue && returnValue.type === "function_call_output") + } + else { + calls = functionDefinition.getDescendants(false).filter(child => child.type == "function_call_output"); + } + + for (const call of calls) { + const callName = getFunctionName(call); + + if (callName === functionName) return true; + + if (visited[callName]) continue; + visited[callName] = true; + + if (checkForCallRecursive(getDefinition(callName, call.workspace))) { + return true; + } + } + + return false; + } +} + +export function getEscapedCBParameters(b: Blockly.Block, stdfun: StdFunc, e: Environment): string[] { + return getCBParameters(b, stdfun, e).map(binding => lookup(e, b, binding.name).escapedName); +} + +function getCBParameters(b: Blockly.Block, stdfun: StdFunc, e: Environment): DeclaredVariable[] { + let handlerArgs: DeclaredVariable[] = []; + if (stdfun.attrs.draggableParameters) { + for (let i = 0; i < stdfun.comp.handlerArgs.length; i++) { + const arg = stdfun.comp.handlerArgs[i]; + let varName: string; + const varBlock = getInputTargetBlock(e, b, "HANDLER_DRAG_PARAM_" + arg.name) as Blockly.Block; + + if (stdfun.attrs.draggableParameters === "reporter") { + varName = varBlock && varBlock.getFieldValue("VALUE"); + } else { + varName = varBlock && varBlock.getField("VAR").getText(); + } + + if (varName !== null) { + handlerArgs.push({ + name: varName, + type: mkPoint(arg.type) + }); + } + else { + break; + } + } + } + else { + for (let i = 0; i < stdfun.comp.handlerArgs.length; i++) { + const arg = stdfun.comp.handlerArgs[i]; + const varField = b.getField("HANDLER_" + arg.name); + const varName = varField && varField.getText(); + if (varName !== null) { + handlerArgs.push({ + name: varName, + type: mkPoint(arg.type) + }); + } + else { + break; + } + } + } + return handlerArgs; +} + +function isPlaceholderBlock(b: Blockly.Block): b is PlaceholderLikeBlock { + return b.type == "placeholder" || b.type === pxtc.TS_OUTPUT_TYPE; +} + +// Internal error (in our code). Compilation shouldn't proceed. +function assert(x: boolean) { + if (!x) + throw new Error("Assertion failure"); +} + +export function defaultValueForType(t: Point): pxt.blocks.JsNode { + if (t.type == null) { + union(t, ground(pNumber.type)); + t = find(t); + } + + if (isArrayType(t.type) || t.isArrayType) { + return pxt.blocks.mkText("[]"); + } + + switch (t.type) { + case "boolean": + return pxt.blocks.H.mkBooleanLiteral(false); + case "number": + return pxt.blocks.H.mkNumberLiteral(0); + case "string": + return pxt.blocks.H.mkStringLiteral(""); + default: + return pxt.blocks.mkText("null"); + } +} + +export function isStringType(type: Point) { + return type === pString; +} + +export function isBooleanType(type: Point) { + return type === pBoolean; +} \ No newline at end of file diff --git a/newblocks/compiler/util.ts b/newblocks/compiler/util.ts new file mode 100644 index 000000000000..3166e3c2067e --- /dev/null +++ b/newblocks/compiler/util.ts @@ -0,0 +1,145 @@ +import * as Blockly from "blockly"; +import { Environment, Scope, StdFunc } from "./environment"; +import { MutatingBlock } from "../legacyMutations"; + + +export function forEachChildExpression(block: Blockly.Block, cb: (block: Blockly.Block) => void, recursive = false) { + block.inputList.filter(i => i.type === Blockly.inputTypes.VALUE).forEach(i => { + if (i.connection && i.connection.targetBlock()) { + cb(i.connection.targetBlock()); + if (recursive) { + forEachChildExpression(i.connection.targetBlock(), cb, recursive); + } + } + }); +} + +export function forEachStatementInput(block: Blockly.Block, cb: (block: Blockly.Block) => void) { + block.inputList.filter(i => i.type === Blockly.inputTypes.STATEMENT).forEach(i => { + if (i.connection && i.connection.targetBlock()) { + cb(i.connection.targetBlock()); + } + }) +} + +export function printScope(scope: Scope, depth = 0) { + const declared = Object.keys(scope.declaredVars).map(k => `${k}(${scope.declaredVars[k].id})`).join(","); + const referenced = scope.referencedVars.join(", "); + console.log(`${mkIndent(depth)}SCOPE: ${scope.firstStatement ? scope.firstStatement.type : "TOP-LEVEL"}`) + if (declared.length) { + console.log(`${mkIndent(depth)}DECS: ${declared}`) + } + // console.log(`${mkIndent(depth)}REFS: ${referenced}`) + scope.children.forEach(s => printScope(s, depth + 1)); +} + +function mkIndent(depth: number) { + let res = ""; + for (let i = 0; i < depth; i++) { + res += " "; + } + return res; +} + +export function getLoopVariableField(e: Environment, b: Blockly.Block) { + return (b.type == "pxt_controls_for" || b.type == "pxt_controls_for_of") ? + getInputTargetBlock(e, b, "VAR") : b; +} + +export function getFunctionName(functionBlock: Blockly.Block) { + return functionBlock.getField("function_name").getText(); +} + +export function visibleParams({ comp }: StdFunc, optionalCount: number) { + const res: pxt.blocks.BlockParameter[] = []; + if (comp.thisParameter) { + res.push(comp.thisParameter); + } + + comp.parameters.forEach(p => { + if (p.isOptional && optionalCount > 0) { + res.push(p); + --optionalCount; + } + else if (!p.isOptional) { + res.push(p); + } + }); + + return res; +} + +export function countOptionals(b: Blockly.Block, func: StdFunc) { + if (func.attrs.compileHiddenArguments) { + return func.comp.parameters.reduce((prev, block) => { + if (block.isOptional) prev++; + return prev + }, 0); + } + if ((b as MutatingBlock).mutationToDom) { + const el = (b as MutatingBlock).mutationToDom(); + if (el.hasAttribute("_expanded")) { + const val = parseInt(el.getAttribute("_expanded")); + return isNaN(val) ? 0 : Math.max(val, 0); + } + } + return 0; +} + +export function getInputTargetBlock(e: Environment, b: Blockly.Block, n: string): Blockly.Block { + const res = b.getInputTargetBlock(n); + + if (!res) { + return e.placeholders[b.id]?.[n]; + } + else { + return res + } +} + +export function isMutatingBlock(b: Blockly.Block): b is MutatingBlock { + return !!(b as MutatingBlock).mutation; +} + +// convert to javascript friendly name +export function escapeVarName(name: string, e: Environment, isFunction = false): string { + if (!name) return '_'; + + if (isFunction) { + if (e.renames.oldToNewFunctions[name]) { + return e.renames.oldToNewFunctions[name]; + } + } + else if (e.renames.oldToNew[name]) { + return e.renames.oldToNew[name]; + } + + let n = ts.pxtc.escapeIdentifier(name); + + if (e.renames.takenNames[n]) { + let i = 2; + + while (e.renames.takenNames[n + i]) { + i++; + } + + n += i; + } + + if (isFunction) { + e.renames.oldToNewFunctions[name] = n; + e.renames.takenNames[n] = true; + } + else { + e.renames.oldToNew[name] = n; + } + return n; +} + +export function append(a1: T[], a2: T[]) { + a1.push.apply(a1, a2); +} + +export function isFunctionDefinition(b: Blockly.Block) { + return b.type === "procedures_defnoreturn" || b.type === "function_definition"; +} \ No newline at end of file diff --git a/newblocks/compiler/variables.ts b/newblocks/compiler/variables.ts new file mode 100644 index 000000000000..d9a2e6b61ca0 --- /dev/null +++ b/newblocks/compiler/variables.ts @@ -0,0 +1,279 @@ +/// + + +import * as Blockly from "blockly"; + +import { Scope, Environment, BlockDeclarationType, VarInfo, GrayBlockStatement } from "./environment"; +import { getDeclaredVariables, mkPoint } from "./typeChecker"; +import { forEachChildExpression, forEachStatementInput } from "./util"; + + +export function trackAllVariables(topBlocks: Blockly.Block[], e: Environment) { + let id = 1; + let topScope: Scope; + + // First, look for on-start + topBlocks.forEach(block => { + if (block.type === ts.pxtc.ON_START_TYPE) { + const firstStatement = block.getInputTargetBlock("HANDLER"); + if (firstStatement) { + topScope = { + firstStatement: firstStatement, + declaredVars: {}, + referencedVars: [], + children: [], + assignedVars: [] + } + trackVariables(firstStatement, topScope, e); + } + } + }); + + // If we didn't find on-start, then create an empty top scope + if (!topScope) { + topScope = { + firstStatement: null, + declaredVars: {}, + referencedVars: [], + children: [], + assignedVars: [] + } + } + + topBlocks.forEach(block => { + if (block.type === ts.pxtc.ON_START_TYPE) { + return; + } + trackVariables(block, topScope, e); + }); + + Object.keys(topScope.declaredVars).forEach(varName => { + const varID = topScope.declaredVars[varName]; + delete topScope.declaredVars[varName]; + const declaringScope = findCommonScope(topScope, varID.id) || topScope; + declaringScope.declaredVars[varName] = varID; + }) + + markDeclarationLocations(topScope, e); + escapeVariables(topScope, e); + + return topScope; + + function trackVariables(block: Blockly.Block, currentScope: Scope, e: Environment) { + e.idToScope[block.id] = currentScope; + + if (block.type === "variables_get") { + const name = block.getField("VAR").getText(); + const info = findOrDeclareVariable(name, currentScope); + currentScope.referencedVars.push(info.id); + } + else if (block.type === "variables_set" || block.type === "variables_change") { + const name = block.getField("VAR").getText(); + const info = findOrDeclareVariable(name, currentScope); + currentScope.assignedVars.push(info.id); + currentScope.referencedVars.push(info.id); + } + else if (block.type === pxtc.TS_STATEMENT_TYPE) { + const declaredVars: string = (block as GrayBlockStatement).declaredVariables + if (declaredVars) { + const varNames = declaredVars.split(","); + varNames.forEach(vName => { + const info = findOrDeclareVariable(vName, currentScope); + info.alreadyDeclared = BlockDeclarationType.Argument; + }); + } + } + + if (hasStatementInput(block)) { + const vars: VarInfo[] = getDeclaredVariables(block, e).map(binding => { + return { + ...binding, + id: id++ + } + }); + + + let parentScope = currentScope; + if (vars.length) { + // We need to create a scope for this block, and then a scope + // for each statement input (in case there are multiple) + + parentScope = { + parent: currentScope, + firstStatement: block, + declaredVars: {}, + referencedVars: [], + assignedVars: [], + children: [] + }; + + vars.forEach(v => { + v.alreadyDeclared = BlockDeclarationType.Assigned; + parentScope.declaredVars[v.name] = v; + }); + + e.idToScope[block.id] = parentScope; + } + + + if (currentScope !== parentScope) { + currentScope.children.push(parentScope); + } + + forEachChildExpression(block, child => { + trackVariables(child, parentScope, e); + }); + + forEachStatementInput(block, connectedBlock => { + const newScope: Scope = { + parent: parentScope, + firstStatement: connectedBlock, + declaredVars: {}, + referencedVars: [], + assignedVars: [], + children: [] + }; + parentScope.children.push(newScope); + trackVariables(connectedBlock, newScope, e); + }); + } + else { + forEachChildExpression(block, child => { + trackVariables(child, currentScope, e); + }); + } + + if (block.nextConnection && block.nextConnection.targetBlock()) { + trackVariables(block.nextConnection.targetBlock(), currentScope, e); + } + } + + function findOrDeclareVariable(name: string, scope: Scope): VarInfo { + if (scope.declaredVars[name]) { + return scope.declaredVars[name]; + } + else if (scope.parent) { + return findOrDeclareVariable(name, scope.parent); + } + else { + // Declare it in the top scope + scope.declaredVars[name] = { + name, + type: mkPoint(null), + id: id++ + }; + return scope.declaredVars[name]; + } + } +} + +function markDeclarationLocations(scope: Scope, e: Environment) { + const declared = Object.keys(scope.declaredVars); + if (declared.length) { + const decls = declared.map(name => scope.declaredVars[name]); + + if (scope.firstStatement) { + // If we can't find a better place to declare the variable, we'll declare + // it before the first statement in the code block so we need to keep + // track of the blocks ids + e.blockDeclarations[scope.firstStatement.id] = decls.concat(e.blockDeclarations[scope.firstStatement.id] || []); + } + + decls.forEach(d => e.allVariables.push(d)); + } + + scope.children.forEach(child => markDeclarationLocations(child, e)); +} + +function findCommonScope(current: Scope, varID: number): Scope { + let ref: Scope; + + if (current.referencedVars.indexOf(varID) !== -1) { + return current; + } + + for (const child of current.children) { + if (referencedWithinScope(child, varID)) { + if (assignedWithinScope(child, varID)) { + return current; + } + if (!ref) { + ref = child; + } + else { + return current; + } + } + } + + return ref ? findCommonScope(ref, varID) : undefined; +} + +function referencedWithinScope(scope: Scope, varID: number) { + if (scope.referencedVars.indexOf(varID) !== -1) { + return true; + } + else { + for (const child of scope.children) { + if (referencedWithinScope(child, varID)) return true; + } + } + return false; +} + +function assignedWithinScope(scope: Scope, varID: number) { + if (scope.assignedVars.indexOf(varID) !== -1) { + return true; + } + else { + for (const child of scope.children) { + if (assignedWithinScope(child, varID)) return true; + } + } + return false; +} + +function escapeVariables(current: Scope, e: Environment) { + for (const varName of Object.keys(current.declaredVars)) { + const info = current.declaredVars[varName]; + if (!info.escapedName) info.escapedName = escapeVarName(varName); + } + + current.children.forEach(c => escapeVariables(c, e)); + + + function escapeVarName(originalName: string): string { + if (!originalName) return '_'; + + let n = ts.pxtc.escapeIdentifier(originalName); + + if (e.renames.takenNames[n] || nameIsTaken(n, current, originalName)) { + let i = 2; + + while (e.renames.takenNames[n + i] || nameIsTaken(n + i, current, originalName)) { + i++; + } + + n += i; + } + + return n; + } + + function nameIsTaken(name: string, scope: Scope, originalName: string): boolean { + if (scope) { + for (const varName of Object.keys(scope.declaredVars)) { + const info = scope.declaredVars[varName]; + if ((originalName !== info.name || info.name !== info.escapedName) && info.escapedName === name) + return true; + } + return nameIsTaken(name, scope.parent, originalName); + } + + return false; + } +} + +function hasStatementInput(block: Blockly.Block) { + return block.inputList.some(i => i.type === Blockly.inputTypes.STATEMENT); +} \ No newline at end of file diff --git a/newblocks/composableMutations.ts b/newblocks/composableMutations.ts new file mode 100644 index 000000000000..9a72b0bc325e --- /dev/null +++ b/newblocks/composableMutations.ts @@ -0,0 +1,401 @@ +/// + +import * as Blockly from "blockly"; +import { createShadowValue } from "./toolbox"; +import { MutatingBlock, setVarFieldValue } from "./legacyMutations"; +import { optionalDummyInputPrefix, optionalInputWithFieldPrefix } from "./constants"; +import { FieldArgumentVariable } from "./fields/field_argumentvariable"; + +export interface ComposableMutation { + // Set to save mutations. Should return an XML element + mutationToDom(mutationElement: Element): Element; + // Set to restore mutations from save + domToMutation(savedElement: Element): void; +} + +export function appendMutation(block: Blockly.Block, mutation: ComposableMutation) { + const b = block as unknown as MutatingBlock; + + const oldMTD = b.mutationToDom; + const oldDTM = b.domToMutation; + + b.mutationToDom = () => { + const el = oldMTD ? oldMTD() : document.createElement("mutation"); + return mutation.mutationToDom(el); + }; + + b.domToMutation = saved => { + if (oldDTM) { + oldDTM(saved); + } + mutation.domToMutation(saved); + } +} + +export function initVariableArgsBlock(b: Blockly.Block, handlerArgs: pxt.blocks.HandlerArg[]) { + let currentlyVisible = 0; + let actuallyVisible = 0; + + let i = b.appendDummyInput(); + let updateShape = () => { + if (currentlyVisible === actuallyVisible) { + return; + } + + if (currentlyVisible > actuallyVisible) { + const diff = currentlyVisible - actuallyVisible; + for (let j = 0; j < diff; j++) { + const arg = handlerArgs[actuallyVisible + j]; + i.insertFieldAt(i.fieldRow.length - 1, new FieldArgumentVariable(arg.name), "HANDLER_" + arg.name); + const blockSvg = b as Blockly.BlockSvg; + if (blockSvg?.initSvg) blockSvg.initSvg(); // call initSvg on block to initialize new fields + } + } + else { + let diff = actuallyVisible - currentlyVisible; + for (let j = 0; j < diff; j++) { + const arg = handlerArgs[actuallyVisible - j - 1]; + i.removeField("HANDLER_" + arg.name); + } + } + + if (currentlyVisible >= handlerArgs.length) { + i.removeField("_HANDLER_ADD"); + } + else if (actuallyVisible >= handlerArgs.length) { + addPlusButton(); + } + + actuallyVisible = currentlyVisible; + }; + + Blockly.Extensions.apply('inline-svgs', b, false); + addPlusButton(); + + appendMutation(b, { + mutationToDom: (el: Element) => { + el.setAttribute("numArgs", currentlyVisible.toString()); + + for (let j = 0; j < currentlyVisible; j++) { + const varField = b.getField("HANDLER_" + handlerArgs[j].name); + let varName = varField && varField.getText(); + el.setAttribute("arg" + j, varName); + } + + return el; + }, + domToMutation: (saved: Element) => { + let numArgs = parseInt(saved.getAttribute("numargs")); + currentlyVisible = Math.min(isNaN(numArgs) ? 0 : numArgs, handlerArgs.length); + + updateShape(); + + for (let j = 0; j < currentlyVisible; j++) { + const varName = saved.getAttribute("arg" + j); + const fieldName = "HANDLER_" + handlerArgs[j].name; + if (b.getField(fieldName)) { + setVarFieldValue(b, fieldName, varName); + } + } + } + }); + + function addPlusButton() { + i.appendField(new Blockly.FieldImage((b as any).ADD_IMAGE_DATAURI, 24, 24, lf("Add argument"), + () => { + currentlyVisible = Math.min(currentlyVisible + 1, handlerArgs.length); + updateShape(); + }, false), "_HANDLER_ADD"); + } +} + +export function initExpandableBlock(info: pxtc.BlocksInfo, b: Blockly.Block, def: pxtc.ParsedBlockDef, comp: pxt.blocks.BlockCompileInfo, toggle: boolean, addInputs: () => void) { + // Add numbers before input names to prevent clashes with the ones added + // by BlocklyLoader. The number makes it an invalid JS identifier + const buttonAddName = "0_add_button"; + const buttonRemName = "0_rem_button"; + const buttonAddRemName = "0_add_rem_button"; + const numVisibleAttr = "_expanded"; + const inputInitAttr = "_input_init"; + + const optionNames = def.parameters.map(p => p.name); + const totalOptions = def.parameters.length; + const buttonDelta = toggle ? totalOptions : 1; + const variableInlineInputs = info.blocksById[b.type].attributes.inlineInputMode === "variable"; + const inlineInputModeLimit = info.blocksById[b.type].attributes.inlineInputModeLimit || 4; + const compileHiddenArguments = info.blocksById[b.type].attributes.compileHiddenArguments; + const breakString = info.blocksById[b.type].attributes.expandableArgumentBreaks; + + let breaks: number[]; + if (breakString) { + breaks = breakString.split(/[;,]/).map(s => parseInt(s)); + } + + const state = new MutationState(b as unknown as MutatingBlock); + state.setEventsEnabled(false); + state.setValue(numVisibleAttr, 0); + state.setValue(inputInitAttr, false); + state.setEventsEnabled(true); + + Blockly.Extensions.apply('inline-svgs', b, false); + + let updatingInputs = false; + let firstRender = true; + + appendMutation(b, { + mutationToDom: (el: Element) => { + // The reason we store the inputsInitialized variable separately from visibleOptions + // is because it's possible for the block to get into a state where all inputs are + // initialized but they aren't visible (i.e. the user hit the - button). Blockly + // gets upset if a block has a different number of inputs when it is saved and restored. + el.setAttribute(numVisibleAttr, state.getString(numVisibleAttr)); + el.setAttribute(inputInitAttr, state.getString(inputInitAttr)); + return el; + }, + domToMutation: (saved: Element) => { + state.setEventsEnabled(false); + if (saved.hasAttribute(inputInitAttr) && saved.getAttribute(inputInitAttr) == "true" && !state.getBoolean(inputInitAttr)) { + state.setValue(inputInitAttr, true) + } + initOptionalInputs(); + + if (saved.hasAttribute(numVisibleAttr)) { + const val = parseInt(saved.getAttribute(numVisibleAttr)); + if (!isNaN(val)) { + const delta = val - (state.getNumber(numVisibleAttr) || 0); + if (state.getBoolean(inputInitAttr)) { + if ((b as Blockly.BlockSvg).rendered || b.isInsertionMarker()) { + updateShape(delta, true, b.isInsertionMarker()); + } + else { + state.setValue(numVisibleAttr, addDelta(delta)); + updateButtons(); + } + } + else { + updateShape(delta, true); + } + } + } + state.setEventsEnabled(true); + } + }); + + initOptionalInputs(); + if (compileHiddenArguments) { + // Make sure all inputs have shadow blocks attached + let optIndex = 0 + for (let i = 0; i < b.inputList.length; i++) { + const input = b.inputList[i]; + if (pxt.Util.startsWith(input.name, optionalInputWithFieldPrefix) || optionNames.indexOf(input.name) !== -1) { + if (input.connection && !(input.connection as any).isConnected() && !b.isInsertionMarker()) { + const param = comp.definitionNameToParam[def.parameters[optIndex].name]; + attachShadowBlock(input, param); + } + ++optIndex; + } + } + } + + + // FIXME (riknoll) + // (b as Blockly.BlockSvg).render = (opt_bubble) => { + // if (updatingInputs) return; + // if (firstRender) { + // firstRender = false; + // updatingInputs = true; + // updateShape(0, undefined, true); + // updatingInputs = false; + // } + // Blockly.BlockSvg.prototype.render.call(b, opt_bubble); + // } + + // Set skipRender to true if the block is still initializing. Otherwise + // the inputs will render before their shadow blocks are created and + // leave behind annoying artifacts + function updateShape(delta: number, skipRender = false, force = false) { + const newValue = addDelta(delta); + if (!force && !skipRender && newValue === state.getNumber(numVisibleAttr)) return; + + state.setValue(numVisibleAttr, newValue); + const visibleOptions = newValue; + + if (!state.getBoolean(inputInitAttr) && visibleOptions > 0) { + initOptionalInputs(); + if (!(b as Blockly.BlockSvg).rendered) { + return; + } + } + + let optIndex = 0 + for (let i = 0; i < b.inputList.length; i++) { + const input = b.inputList[i]; + if (pxt.Util.startsWith(input.name, optionalDummyInputPrefix)) { + // The behavior for dummy inputs (i.e. labels) is that whenever a parameter is revealed, + // all earlier labels are made visible as well. If the parameter is the last one in the + // block then all labels are made visible + setInputVisible(input, optIndex < visibleOptions || visibleOptions === totalOptions); + } + else if (pxt.Util.startsWith(input.name, optionalInputWithFieldPrefix) || optionNames.indexOf(input.name) !== -1) { + const visible = optIndex < visibleOptions; + setInputVisible(input, visible); + if (visible && input.connection && !(input.connection as any).isConnected() && !b.isInsertionMarker()) { + const param = comp.definitionNameToParam[def.parameters[optIndex].name]; + attachShadowBlock(input, param); + } + ++optIndex; + } + } + + updateButtons(); + if (variableInlineInputs) b.setInputsInline(visibleOptions < inlineInputModeLimit); + if (!skipRender) (b as Blockly.BlockSvg).render(); + } + + function addButton(name: string, uri: string, alt: string, delta: number) { + b.appendDummyInput(name) + .appendField(new Blockly.FieldImage(uri, 24, 24, alt, () => updateShape(delta), false)) + } + + function updateButtons() { + if (updatingInputs) return; + const visibleOptions = state.getNumber(numVisibleAttr); + const showPlus = visibleOptions !== totalOptions; + const showMinus = visibleOptions !== 0; + + if (b.inputList.some(i => i.name === buttonAddName)) b.removeInput(buttonAddName, true); + if (b.inputList.some(i => i.name === buttonRemName)) b.removeInput(buttonRemName, true); + if (b.inputList.some(i => i.name === buttonAddRemName)) b.removeInput(buttonAddRemName, true); + + if (showPlus && showMinus) { + addPlusAndMinusButtons(); + } + else if (showPlus) { + addPlusButton(); + } + else if (showMinus) { + addMinusButton(); + } + } + + function addPlusAndMinusButtons() { + b.appendDummyInput(buttonAddRemName) + .appendField(new Blockly.FieldImage((b as any).REMOVE_IMAGE_DATAURI, 24, 24, lf("Hide optional arguments"), () => updateShape(-1 * buttonDelta), false)) + .appendField(new Blockly.FieldImage((b as any).ADD_IMAGE_DATAURI, 24, 24, lf("Reveal optional arguments"), () => updateShape(buttonDelta), false)) + } + + function addPlusButton() { + addButton(buttonAddName, (b as any).ADD_IMAGE_DATAURI, lf("Reveal optional arguments"), buttonDelta); + } + + function addMinusButton() { + addButton(buttonRemName, (b as any).REMOVE_IMAGE_DATAURI, lf("Hide optional arguments"), -1 * buttonDelta); + } + + function initOptionalInputs() { + state.setValue(inputInitAttr, true); + addInputs(); + updateButtons(); + } + + function addDelta(delta: number) { + const newValue = Math.min(Math.max(state.getNumber(numVisibleAttr) + delta, 0), totalOptions); + + if (breaks) { + if (delta >= 0) { + if (newValue === 0) return 0; + for (const breakpoint of breaks) { + if (breakpoint >= newValue) { + return breakpoint; + } + } + return totalOptions; + } + else { + for (let i = 0; i < breaks.length; i++) { + if (breaks[i] >= newValue) { + return i > 0 ? breaks[i - 1] : 0; + } + } + return breaks[breaks.length - 1]; + } + + } + + return newValue; + } + + function setInputVisible(input: Blockly.Input, visible: boolean) { + // If the block isn't rendered, Blockly will crash + input.setVisible(visible); + } + + function attachShadowBlock(input: Blockly.Input, param: pxt.blocks.BlockParameter) { + let shadow = createShadowValue(info, param); + + if (shadow.tagName.toLowerCase() === "value") { + // Unwrap the block + shadow = shadow.firstElementChild; + } + + Blockly.Events.disable(); + + try { + const nb = Blockly.Xml.domToBlock(shadow, b.workspace); + if (nb) { + input.connection.connect(nb.outputConnection); + } + } catch (e) { } + + Blockly.Events.enable(); + } +} + +class MutationState { + private state: pxt.Map; + private fireEvents = true; + + constructor(public block: MutatingBlock, initState?: pxt.Map) { + this.state = initState || {}; + } + + setValue(attr: string, value: boolean | number | string) { + if (this.fireEvents && this.block.mutationToDom) { + const oldMutation = this.block.mutationToDom(); + this.state[attr] = value.toString(); + const newMutation = this.block.mutationToDom(); + + Object.keys(this.state).forEach(key => { + if (oldMutation.getAttribute(key) !== this.state[key]) { + newMutation.setAttribute(key, this.state[key]); + } + }); + + const oldText = Blockly.Xml.domToText(oldMutation); + const newText = Blockly.Xml.domToText(newMutation); + + if (oldText != newText) { + Blockly.Events.fire(new Blockly.Events.BlockChange(this.block as unknown as Blockly.Block, "mutation", null, oldText, newText)); + } + } + else { + this.state[attr] = value.toString(); + } + } + + getNumber(attr: string) { + return parseInt(this.state[attr]); + } + + getBoolean(attr: string) { + return this.state[attr] != "false"; + } + + getString(attr: string) { + return this.state[attr]; + } + + setEventsEnabled(enabled: boolean) { + this.fireEvents = enabled; + } +} \ No newline at end of file diff --git a/newblocks/constants.ts b/newblocks/constants.ts new file mode 100644 index 000000000000..a77ccb0d6309 --- /dev/null +++ b/newblocks/constants.ts @@ -0,0 +1,6 @@ +import * as Blockly from "blockly"; + +export let provider = new Blockly.zelos.ConstantProvider(); + +export const optionalDummyInputPrefix = "0_optional_dummy"; +export const optionalInputWithFieldPrefix = "0_optional_field"; \ No newline at end of file diff --git a/newblocks/diff.ts b/newblocks/diff.ts new file mode 100644 index 000000000000..f475ad076f34 --- /dev/null +++ b/newblocks/diff.ts @@ -0,0 +1,547 @@ +/// + +import * as Blockly from "blockly"; +import { domToWorkspaceNoEvents, loadWorkspaceXml, saveWorkspaceXml } from "./importer"; +import { BlockLayout, BlocksRenderOptions, initRenderingWorkspace, renderWorkspace } from "./render"; + +export interface DiffOptions { + hideDeletedTopBlocks?: boolean; + hideDeletedBlocks?: boolean; + renderOptions?: BlocksRenderOptions; + statementsOnly?: boolean; // consider statement as a whole +} + +export interface DiffResult { + ws?: Blockly.WorkspaceSvg; + message?: string; + error?: any; + svg?: Element; + deleted: number; + added: number; + modified: number; +} + +// sniff ids to see if the xml was completly reconstructed +export function needsDecompiledDiff(oldXml: string, newXml: string): boolean { + if (!oldXml || !newXml) + return false; + // collect all ids + const oldids: pxt.Map = {}; + oldXml.replace(/id="([^"]+)"/g, (m, id) => { oldids[id] = true; return ""; }); + if (!Object.keys(oldids).length) + return false; + // test if any newid exists in old + let total = 0; + let found = 0; + newXml.replace(/id="([^"]+)"/g, (m, id) => { + total++; + if (oldids[id]) + found++; + return ""; + }); + return total > 0 && found == 0; +} + +export function diffXml(oldXml: string, newXml: string, options?: DiffOptions): DiffResult { + const oldWs = loadWorkspaceXml(oldXml, true); + const newWs = loadWorkspaceXml(newXml, true); + return diffWorkspace(oldWs, newWs, options); +} + +const UNMODIFIED_COLOR = "#d0d0d0"; +// Workspaces are modified in place! +function diffWorkspace(oldWs: Blockly.Workspace, newWs: Blockly.Workspace, options?: DiffOptions): DiffResult { + try { + Blockly.Events.disable(); + return diffWorkspaceNoEvents(oldWs, newWs, options); + } + catch (e) { + pxt.reportException(e); + return { + ws: undefined, + message: lf("Oops, we could not diff those blocks."), + error: e, + deleted: 0, + added: 0, + modified: 0 + } + } finally { + Blockly.Events.enable(); + } +} + +function logger() { + const log = pxt.options.debug || (window && /diffdbg=1/.test(window.location.href)) + ? console.log : (message?: any, ...args: any[]) => { }; + return log; + +} + +function diffWorkspaceNoEvents(oldWs: Blockly.Workspace, newWs: Blockly.Workspace, options?: DiffOptions): DiffResult { + pxt.tickEvent("blocks.diff", { started: 1 }) + options = options || {}; + const log = logger(); + if (!oldWs) { + return { + ws: undefined, + message: lf("All blocks are new."), + added: 0, + deleted: 0, + modified: 1 + }; // corrupted blocks + } + if (!newWs) { + return { + ws: undefined, + message: lf("The current blocks seem corrupted."), + added: 0, + deleted: 0, + modified: 1 + }; // corrupted blocks + } + + // remove all unmodified topblocks + // when doing a Blocks->TS roundtrip, all ids are trashed. + const oldXml: pxt.Map = pxt.Util.toDictionary(oldWs.getTopBlocks(false), b => normalizedDom(b, true)); + newWs.getTopBlocks(false) + .forEach(newb => { + const newn = normalizedDom(newb, true); + // try to find by id or by matching normalized xml + const oldb = oldWs.getBlockById(newb.id) || oldXml[newn]; + if (oldb) { + const oldn = normalizedDom(oldb, true); + if (newn == oldn) { + log(`fast unmodified top `, newb.id); + newb.dispose(false); + oldb.dispose(false); + } + } + }) + + // we'll ignore disabled blocks in the final output + + const oldBlocks = oldWs.getAllBlocks(false).filter(b => b.isEnabled()); + const oldTopBlocks = oldWs.getTopBlocks(false).filter(b => b.isEnabled()); + const newBlocks = newWs.getAllBlocks(false).filter(b => b.isEnabled()); + log(`blocks`, newBlocks.map(b => b.toDevString())); + log(newBlocks); + + if (oldBlocks.length == 0 && newBlocks.length == 0) { + pxt.tickEvent("blocks.diff", { moves: 1 }) + return { + ws: undefined, + message: lf("Some blocks were moved or changed."), + added: 0, + deleted: 0, + modified: 1 + }; // just moves + } + + // locate deleted and added blocks + const deletedTopBlocks = oldTopBlocks.filter(b => !newWs.getBlockById(b.id)); + const deletedBlocks = oldBlocks.filter(b => !newWs.getBlockById(b.id)); + const addedBlocks = newBlocks.filter(b => !oldWs.getBlockById(b.id)); + + // clone new workspace into rendering workspace + const ws = initRenderingWorkspace(); + const newXml = saveWorkspaceXml(newWs, true); + domToWorkspaceNoEvents(Blockly.utils.xml.textToDom(newXml), ws); + + // delete disabled blocks from final workspace + ws.getAllBlocks(false).filter(b => !b.isEnabled()).forEach(b => { + log('disabled ', b.toDevString()) + b.dispose(false) + }) + const todoBlocks = pxt.Util.toDictionary(ws.getAllBlocks(false), b => b.id); + log(`todo blocks`, todoBlocks) + logTodo('start') + + // 1. deleted top blocks + if (!options.hideDeletedTopBlocks) { + deletedTopBlocks.forEach(b => { + log(`deleted top ${b.toDevString()}`) + done(b); + const b2 = cloneIntoDiff(b); + done(b2); + b2.setEnabled(false); + }); + logTodo('deleted top') + } + + // 2. added blocks + addedBlocks.map(b => ws.getBlockById(b.id)) + .filter(b => !!b) // ignore disabled + .forEach(b => { + log(`added ${b.toDevString()}`) + //b.inputList[0].insertFieldAt(0, new Blockly.FieldImage(ADD_IMAGE_DATAURI, 24, 24, false)); + done(b); + }); + logTodo('added') + + // 3. delete statement blocks + // inject deleted blocks in new workspace + const dids: pxt.Map = {}; + if (!options.hideDeletedBlocks) { + const deletedStatementBlocks = deletedBlocks + .filter(b => !todoBlocks[b.id] + && !isUsed(b) + && (!b.outputConnection || !b.outputConnection.isConnected()) // ignore reporters + ); + deletedStatementBlocks + .forEach(b => { + const b2 = cloneIntoDiff(b); + dids[b.id] = b2.id; + log(`deleted block ${b.toDevString()}->${b2.toDevString()}`) + }) + // connect deleted blocks together + deletedStatementBlocks + .forEach(b => stitch(b)); + } + + // 4. moved blocks + let modified = 0; + pxt.Util.values(todoBlocks).filter(b => moved(b)).forEach(b => { + log(`moved ${b.toDevString()}`) + delete todoBlocks[b.id] + markUsed(b); + modified++; + }) + logTodo('moved') + + // 5. blocks with field properties that changed + pxt.Util.values(todoBlocks).filter(b => changed(b)).forEach(b => { + log(`changed ${b.toDevString()}`) + delete todoBlocks[b.id]; + markUsed(b); + modified++; + }) + logTodo('changed') + + // delete unmodified top blocks + ws.getTopBlocks(false) + .forEach(b => { + if (!findUsed(b)) { + log(`unmodified top ${b.toDevString()}`) + delete todoBlocks[b.id]; + b.dispose(false) + } + }); + logTodo('cleaned') + + // all unmodifed blocks are greyed out + pxt.Util.values(todoBlocks).filter(b => !!ws.getBlockById(b.id)).forEach(b => { + unmodified(b); + }); + logTodo('unmodified') + + // if nothing is left in the workspace, we "missed" change + if (!ws.getAllBlocks(false).length) { + pxt.tickEvent("blocks.diff", { missed: 1 }) + return { + ws, + message: lf("Some blocks were changed."), + deleted: deletedBlocks.length, + added: addedBlocks.length, + modified: modified + } + } + + // make sure everything is rendered + ws.resize(); + Blockly.svgResize(ws); + + // final render + const svg = renderWorkspace(options.renderOptions || { + emPixels: 20, + layout: BlockLayout.Flow, + aspectRatio: 0.5, + useViewWidth: true + }); + + // and we're done + const r: DiffResult = { + ws, + svg: svg, + deleted: deletedBlocks.length, + added: addedBlocks.length, + modified: modified + } + pxt.tickEvent("blocks.diff", { deleted: r.deleted, added: r.added, modified: r.modified }) + return r; + + function stitch(b: Blockly.Block) { + log(`stitching ${b.toDevString()}->${dids[b.id]}`) + const wb = ws.getBlockById(dids[b.id]); + wb.setEnabled(false); + markUsed(wb); + done(wb); + // connect previous connection to delted or existing block + const previous = b.getPreviousBlock(); + if (previous) { + const previousw = ws.getBlockById(dids[previous.id]) || ws.getBlockById(previous.id); + log(`previous ${b.id}->${wb.toDevString()}: ${previousw.toDevString()}`) + if (previousw) { + // either connected under or in the block + if (previousw.nextConnection) + wb.previousConnection.connect(previousw.nextConnection); + else { + const ic = previousw.inputList.slice() + .reverse() + .find(input => input.connection && input.connection.type == Blockly.NEXT_STATEMENT); + if (ic) + wb.previousConnection.connect(ic.connection); + } + } + } + // connect next connection to delete or existing block + const next = b.getNextBlock(); + if (next) { + const nextw = ws.getBlockById(dids[next.id]) || ws.getBlockById(next.id); + if (nextw) { + log(`next ${b.id}->${wb.toDevString()}: ${nextw.toDevString()}`) + wb.nextConnection.connect(nextw.previousConnection); + } + } + } + + function markUsed(b: Blockly.Block) { + (b).__pxt_used = true; + } + + function isUsed(b: Blockly.Block) { + return !!(b).__pxt_used; + } + + function cloneIntoDiff(b: Blockly.Block): Blockly.Block { + const bdom = Blockly.Xml.blockToDom(b, false) as Element; + const b2 = Blockly.Xml.domToBlock(bdom, ws); + // disconnect + if (b2.nextConnection && b2.nextConnection.targetConnection) + b2.nextConnection.disconnect(); + if (b2.previousConnection && b2.previousConnection.targetConnection) + b2.previousConnection.disconnect(); + return b2; + } + + function forceRender(b: Blockly.Block) { + b.rendered = false; + b.inputList.forEach(i => i.fieldRow.forEach(f => { + f.init(); + + // FIXME (riknoll) + // if (f.borderRect_) { + // f.borderRect_.setAttribute('fill', b.getColour()) + // f.borderRect_.setAttribute('stroke', (b as Blockly.BlockSvg).getColourTertiary()) + // } + })); + } + + function done(b: Blockly.Block) { + b.getDescendants(false).forEach(t => { delete todoBlocks[t.id]; markUsed(t); }); + } + + function findUsed(b: Blockly.Block): boolean { + return !!b.getDescendants(false).find(c => isUsed(c)); + } + + function logTodo(msg: string) { + log(`${msg}:`, pxt.Util.values(todoBlocks).map(b => b.toDevString())) + } + + function moved(b: Blockly.Block) { + const oldb = oldWs.getBlockById(b.id); // extra block created in added step + if (!oldb) + return false; + + const newPrevious = b.getPreviousBlock(); + // connection already already processed + if (newPrevious && !todoBlocks[newPrevious.id]) + return false; + const newNext = b.getNextBlock(); + // already processed + if (newNext && !todoBlocks[newNext.id]) + return false; + + const oldPrevious = oldb.getPreviousBlock(); + if (!oldPrevious && !newPrevious) return false; // no connection + if (!!oldPrevious != !!newPrevious // new connection + || oldPrevious.id != newPrevious.id) // new connected blocks + return true; + const oldNext = oldb.getNextBlock(); + if (!oldNext && !newNext) return false; // no connection + if (!!oldNext != !!newNext // new connection + || oldNext.id != newNext.id) // new connected blocks + return true; + return false; + } + + function changed(b: Blockly.Block) { + let oldb = oldWs.getBlockById(b.id); // extra block created in added step + if (!oldb) + return false; + + // normalize + //oldb = copyToTrashWs(oldb); + const oldText = normalizedDom(oldb); + + //b = copyToTrashWs(b); + const newText = normalizedDom(b); + + if (oldText != newText) { + log(`old ${oldb.toDevString()}`, oldText) + log(`new ${b.toDevString()}`, newText) + return true; + } + + // not changed! + return false; + } + + function unmodified(b: Blockly.Block) { + b.setColour(UNMODIFIED_COLOR); + forceRender(b); + + if (options.statementsOnly) { + // mark all nested reporters as unmodified + (b.inputList || []) + .map(input => input.type == Blockly.inputTypes.VALUE && input.connection && input.connection.targetBlock()) + .filter(argBlock => !!argBlock) + .forEach(argBlock => unmodified(argBlock)) + } + } +} + +export function mergeXml(xmlA: string, xmlO: string, xmlB: string): string { + if (xmlA == xmlO) return xmlB; + if (xmlB == xmlO) return xmlA; + + // TODO merge + return undefined; +} + +function normalizedDom(b: Blockly.Block, keepChildren?: boolean): string { + const dom = Blockly.Xml.blockToDom(b, true) as Element; + normalizeAttributes(dom); + visDom(dom, (e) => { + normalizeAttributes(e); + if (!keepChildren) { + if (e.localName == "next") + e.remove(); // disconnect or unplug not working propertly + else if (e.localName == "statement") + e.remove(); + else if (e.localName == "shadow") // ignore internal nodes + e.remove(); + } + }) + return Blockly.Xml.domToText(dom); +} + +function normalizeAttributes(e: Element) { + e.removeAttribute("id"); + e.removeAttribute("x"); + e.removeAttribute("y"); + e.removeAttribute("deletable"); + e.removeAttribute("editable"); + e.removeAttribute("movable") +} + +function visDom(el: Element, f: (e: Element) => void) { + if (!el) return; + f(el); + for (const child of pxt.Util.toArray(el.children)) + visDom(child, f); +} + +export function decompiledDiffAsync(oldTs: string, oldResp: pxtc.CompileResult, newTs: string, newResp: pxtc.CompileResult, options: DiffOptions = {}): DiffResult { + const log = logger(); + + const oldXml = oldResp.outfiles[pxt.MAIN_BLOCKS]; + let newXml = newResp.outfiles[pxt.MAIN_BLOCKS]; + log(oldXml); + log(newXml); + + // compute diff of typescript sources + const diffLines = pxt.diff.compute(oldTs, newTs, { + ignoreWhitespace: true, + full: true + }); + log(diffLines); + + // build old -> new lines mapping + const newids: pxt.Map = {}; + let oldLineStart = 0; + let newLineStart = 0; + diffLines.forEach((ln, index) => { + // moving cursors + const marker = ln[0]; + const line = ln.substr(2); + let lineLength = line.length; + switch (marker) { + case "-": // removed + oldLineStart += lineLength + 1; + break; + case "+": // added + newLineStart += lineLength + 1; + break; + default: // unchanged + // skip leading white space + const lw = /^\s+/.exec(line); + if (lw) { + const lwl = lw[0].length; + oldLineStart += lwl; + newLineStart += lwl; + lineLength -= lwl; + } + // find block ids mapped to the ranges + const newid = findBlockIdByPosition(newResp.blockSourceMap, { + start: newLineStart, + length: lineLength + }); + if (newid && !newids[newid]) { + const oldid = findBlockIdByPosition(oldResp.blockSourceMap, { + start: oldLineStart, + length: lineLength + }); + + // patch workspace + if (oldid) { + log(ln); + log(`id ${oldLineStart}:${line.length}>${oldid} ==> ${newLineStart}:${line.length}>${newid}`) + newids[newid] = oldid; + newXml = newXml.replace(newid, oldid); + } + + } + oldLineStart += lineLength + 1; + newLineStart += lineLength + 1; + break; + } + }) + + // parse workspacews + const oldWs = loadWorkspaceXml(oldXml, true); + const newWs = loadWorkspaceXml(newXml, true); + + options.statementsOnly = true; // no info on expression diffs + return diffWorkspace(oldWs, newWs, options); +} + +function findBlockIdByPosition(sourceMap: pxt.blocks.BlockSourceInterval[], loc: { start: number; length: number; }): string { + if (!loc) return undefined; + let bestChunk: pxt.blocks.BlockSourceInterval; + let bestChunkLength: number; + // look for smallest chunk containing the block + for (let i = 0; i < sourceMap.length; ++i) { + let chunk = sourceMap[i]; + if (chunk.startPos <= loc.start + && chunk.endPos >= loc.start + loc.length + && (!bestChunk || bestChunkLength > chunk.endPos - chunk.startPos)) { + bestChunk = chunk; + bestChunkLength = chunk.endPos - chunk.startPos; + } + } + if (bestChunk) { + return bestChunk.id; + } + return undefined; +} \ No newline at end of file diff --git a/newblocks/external.ts b/newblocks/external.ts new file mode 100644 index 000000000000..acb9651dc742 --- /dev/null +++ b/newblocks/external.ts @@ -0,0 +1,30 @@ +let _promptTranslateBlock: (blockId: string, blockTranslationIds: string[]) => void; + +export function promptTranslateBlock(blockId: string, blockTranslationIds: string[]) { + if (_promptTranslateBlock) { + _promptTranslateBlock(blockId, blockTranslationIds); + } +} + +/** + * This callback is populated from the editor extension result. + * Allows a target to provide version specific blockly updates + */ +let _extensionBlocklyPatch: (pkgTargetVersion: string, el: Element) => void; + +export function extensionBlocklyPatch(pkgTargetVersion: string, el: Element) { + if (_extensionBlocklyPatch) { + _extensionBlocklyPatch(pkgTargetVersion, el); + } +} + +let _openHelpUrl: (url: string) => void; + +export function openHelpUrl(url: string) { + if (_openHelpUrl) { + _openHelpUrl(url); + } + else { + window.open(url); + } +} \ No newline at end of file diff --git a/newblocks/fields/fieldEditorRegistry.ts b/newblocks/fields/fieldEditorRegistry.ts new file mode 100644 index 000000000000..3080a94de302 --- /dev/null +++ b/newblocks/fields/fieldEditorRegistry.ts @@ -0,0 +1,75 @@ +/// + +import * as Blockly from "blockly"; +import { FieldAnimationEditor } from "./field_animation"; +import { FieldTilemap } from "./field_tilemap"; +import { FieldTextInput } from "./field_textinput"; +import { FieldCustom, FieldCustomConstructor } from "./field_utils"; + +interface FieldEditorOptions { + field: FieldCustomConstructor; + validator?: any; +} + +let registeredFieldEditors: pxt.Map = {}; + +export function initFieldEditors() { + registerFieldEditor('text', FieldTextInput); + // registerFieldEditor('note', FieldNote); + // registerFieldEditor('gridpicker', FieldGridPicker); + // registerFieldEditor('textdropdown', FieldTextDropdown); + // registerFieldEditor('numberdropdown', FieldNumberDropdown); + // registerFieldEditor('imagedropdown', FieldImageDropdown); + // registerFieldEditor('colorwheel', FieldColorWheel); + // registerFieldEditor('toggle', FieldToggle); + // registerFieldEditor('toggleonoff', FieldToggleOnOff); + // registerFieldEditor('toggleyesno', FieldToggleYesNo); + // registerFieldEditor('toggleupdown', FieldToggleUpDown); + // registerFieldEditor('toggledownup', FieldToggleDownUp); + // registerFieldEditor('togglehighlow', FieldToggleHighLow); + // registerFieldEditor('togglewinlose', FieldToggleWinLose); + // registerFieldEditor('colornumber', FieldColorNumber); + // registerFieldEditor('images', FieldImages); + // registerFieldEditor('sprite', FieldSpriteEditor); + registerFieldEditor('animation', FieldAnimationEditor); + registerFieldEditor('tilemap', FieldTilemap); +// registerFieldEditor('tileset', FieldTileset); +// registerFieldEditor('speed', FieldSpeed); +// registerFieldEditor('turnratio', FieldTurnRatio); +// registerFieldEditor('protractor', FieldProtractor); +// registerFieldEditor('position', FieldPosition); +// registerFieldEditor('melody', FieldCustomMelody); +// registerFieldEditor('soundeffect', FieldSoundEffect); +// registerFieldEditor('autocomplete', FieldAutoComplete); +// if (pxt.appTarget.appTheme?.songEditor) { +// registerFieldEditor('musiceditor', FieldMusicEditor); +// } +} + +export function registerFieldEditor(selector: string, field: FieldCustomConstructor, validator?: any) { + if (registeredFieldEditors[selector] == undefined) { + registeredFieldEditors[selector] = { + field: field, + validator: validator + } + } +} + +export function createFieldEditor(selector: string, text: string, params: any): FieldCustom { + if (registeredFieldEditors[selector] == undefined) { + console.error(`Field editor ${selector} not registered`); + return null; + } + + if (!params) { + params = {}; + } + + pxt.Util.assert(params.lightMode == undefined, "lightMode is a reserved parameter for custom fields"); + + params.lightMode = pxt.options.light; + + let customField = registeredFieldEditors[selector]; + let instance = new customField.field(text, params, customField.validator); + return instance; +} \ No newline at end of file diff --git a/newblocks/fields/field_animation.ts b/newblocks/fields/field_animation.ts new file mode 100644 index 000000000000..7338cbe9800d --- /dev/null +++ b/newblocks/fields/field_animation.ts @@ -0,0 +1,275 @@ +/// + +import * as Blockly from "blockly"; + +import svg = pxt.svgUtil; +import { FieldAssetEditor } from "./field_asset"; +import { bitmapToImageURI } from "./field_utils"; + +export interface FieldAnimationOptions { + initWidth: string; + initHeight: string; + disableResize: string; + + filter?: string; + lightMode: boolean; +} + +export interface ParsedFieldAnimationOptions { + initWidth: number; + initHeight: number; + disableResize: boolean; + filter?: string; + lightMode: boolean; +} + +// 32 is specifically chosen so that we can scale the images for the default +// sprite sizes without getting browser anti-aliasing +const PREVIEW_WIDTH = 32; +const X_PADDING = 5; +const Y_PADDING = 1; +const BG_PADDING = 4; +const BG_WIDTH = BG_PADDING * 2 + PREVIEW_WIDTH; +const ICON_WIDTH = 30; +const TOTAL_HEIGHT = Y_PADDING * 2 + BG_PADDING * 2 + PREVIEW_WIDTH; +const TOTAL_WIDTH = X_PADDING * 2 + BG_PADDING * 2 + PREVIEW_WIDTH + ICON_WIDTH; + +export class FieldAnimationEditor extends FieldAssetEditor { + protected frames: string[]; + protected preview: svg.Image; + protected animateRef: any; + protected asset: pxt.Animation; + protected initInterval: number; + + initView() { + // Register mouseover events for animating preview + (this.sourceBlock_ as Blockly.BlockSvg).getSvgRoot().addEventListener("mouseenter", this.onMouseEnter); + (this.sourceBlock_ as Blockly.BlockSvg).getSvgRoot().addEventListener("mouseleave", this.onMouseLeave); + } + + showEditor_() { + // Read parent interval + if (this.asset) { + this.asset.interval = this.getParentInterval() || this.asset.interval; + } + + super.showEditor_(); + } + + render_() { + super.render_(); + this.size_.height = TOTAL_HEIGHT + this.size_.width = TOTAL_WIDTH; + } + + protected getAssetType(): pxt.AssetType { + return pxt.AssetType.Animation; + } + + protected createNewAsset(text?: string): pxt.Asset { + const project = pxt.react.getTilemapProject(); + + if (text) { + const existing = pxt.lookupProjectAssetByTSReference(text, project); + if (existing) return existing; + + const frames = parseImageArrayString(text); + + if (frames && frames.length) { + const id = this.sourceBlock_.id; + + const newAnimation: pxt.Animation = { + internalID: -1, + id, + type: pxt.AssetType.Animation, + frames, + interval: this.getParentInterval(), + meta: { }, + }; + return newAnimation; + } + + const asset = project.lookupAssetByName(pxt.AssetType.Animation, text.trim()); + if (asset) return asset; + } + + const id = this.sourceBlock_.id; + const bitmap = new pxt.sprite.Bitmap(this.params.initWidth, this.params.initHeight).data() + + const newAnimation: pxt.Animation = { + internalID: -1, + id, + type: pxt.AssetType.Animation, + frames: [bitmap], + interval: 500, + meta: {}, + }; + + return newAnimation; + } + + protected onEditorClose(newValue: pxt.Animation) { + this.setParentInterval(newValue.interval); + } + + protected getValueText(): string { + if (!this.asset) return "[]"; + + if (this.isTemporaryAsset()) { + return "[" + this.asset.frames.map(frame => + pxt.sprite.bitmapToImageLiteral(pxt.sprite.Bitmap.fromData(frame), pxt.editor.FileType.TypeScript) + ).join(",") + "]" + } + + return pxt.getTSReferenceForAsset(this.asset); + } + + protected redrawPreview() { + if (!this.fieldGroup_) return; + pxsim.U.clear(this.fieldGroup_); + + const bg = new svg.Rect() + .at(X_PADDING + ICON_WIDTH, Y_PADDING) + .size(BG_WIDTH, BG_WIDTH) + .corner(4) + .setClass("blocklyAnimationField"); + + this.fieldGroup_.appendChild(bg.el); + + const icon = new svg.Text("\uf008") + .at(X_PADDING, 5 + (TOTAL_HEIGHT >> 1)) + .setClass("semanticIcon"); + + this.fieldGroup_.appendChild(icon.el); + + if (this.asset) { + this.frames = this.asset.frames.map(frame => bitmapToImageURI(pxt.sprite.Bitmap.fromData(frame), PREVIEW_WIDTH, this.lightMode)); + this.preview = new svg.Image() + .src(this.frames[0]) + .at(X_PADDING + BG_PADDING + ICON_WIDTH, Y_PADDING + BG_PADDING) + .size(PREVIEW_WIDTH, PREVIEW_WIDTH); + this.fieldGroup_.appendChild(this.preview.el); + } + } + + protected onMouseEnter = () => { + if (this.animateRef || !this.asset) return; + + const assetInterval = this.getParentInterval() || this.asset.interval; + + const interval = assetInterval > 50 ? assetInterval : 50; + + let index = 0; + this.animateRef = setInterval(() => { + if (this.preview && this.frames[index]) this.preview.src(this.frames[index]); + index = (index + 1) % this.frames.length; + }, interval); + } + + protected onMouseLeave = () => { + if (this.animateRef) clearInterval(this.animateRef); + this.animateRef = undefined; + + if (this.preview && this.frames[0]) { + this.preview.src(this.frames[0]); + } + } + + protected getParentIntervalBlock(): Blockly.Block { + const s = this.sourceBlock_; + if (s.getParent()) { + const p = s.getParent(); + for (const input of p.inputList) { + if (input.name === "frameInterval") { + return input.connection.targetBlock(); + } + } + } + + return undefined; + } + + protected setParentInterval(interval: number) { + const target = this.getParentIntervalBlock(); + + if (target) { + const fieldName = getFieldName(target); + if (fieldName) { + target.setFieldValue(String(interval), fieldName); + } + } + } + + protected getParentInterval() { + const target = this.getParentIntervalBlock(); + + if (target) { + const fieldName = getFieldName(target); + if (fieldName) { + return Number(target.getFieldValue(fieldName)) + } + } + + return 100; + } + + protected parseFieldOptions(opts: FieldAnimationOptions): ParsedFieldAnimationOptions { + return parseFieldOptions(opts); + } +} + +function parseFieldOptions(opts: FieldAnimationOptions) { + const parsed: ParsedFieldAnimationOptions = { + initWidth: 16, + initHeight: 16, + disableResize: false, + lightMode: false + }; + + if (!opts) { + return parsed; + } + + parsed.lightMode = opts.lightMode; + + if (opts.filter) { + parsed.filter = opts.filter; + } + + parsed.initWidth = withDefault(opts.initWidth, parsed.initWidth); + parsed.initHeight = withDefault(opts.initHeight, parsed.initHeight); + + return parsed; + + function withDefault(raw: string, def: number) { + const res = parseInt(raw); + if (isNaN(res)) { + return def; + } + return res; + } +} + +function parseImageArrayString(str: string): pxt.sprite.BitmapData[] { + if (str.indexOf("[") === -1) return null; + str = str.replace(/[\[\]]/mg, ""); + return str.split(",").map(s => pxt.sprite.imageLiteralToBitmap(s).data()).filter(b => b.height && b.width); +} + +function isNumberType(type: string) { + return type === "math_number" || type === "math_integer" || type === "math_whole_number"; +} + +function getFieldName(target: Blockly.Block) { + if (target.type === "math_number_minmax") { + return "SLIDER"; + } + else if (isNumberType(target.type)) { + return "NUM"; + } + else if (target.type === "timePicker") { + return "ms"; + } + + return null; +} \ No newline at end of file diff --git a/newblocks/fields/field_argumentvariable.ts b/newblocks/fields/field_argumentvariable.ts new file mode 100644 index 000000000000..937e076b2088 --- /dev/null +++ b/newblocks/fields/field_argumentvariable.ts @@ -0,0 +1,20 @@ +/// + +import * as Blockly from "blockly"; + +/** + * Subclass of FieldVariable to filter out the "delete" option when + * variables are part of a function argument (or else the whole function + * gets deleted). +*/ +export class FieldArgumentVariable extends Blockly.FieldVariable { + constructor(varName: string) { + super(varName); + this.menuGenerator_ = this.generateMenu; + } + + generateMenu(): any { + const options = Blockly.FieldVariable.dropdownCreate.call(this); + return options.filter((opt: any) => opt[1] != Blockly.DELETE_VARIABLE_ID); + } +} \ No newline at end of file diff --git a/newblocks/fields/field_asset.ts b/newblocks/fields/field_asset.ts new file mode 100644 index 000000000000..a49ded5fb187 --- /dev/null +++ b/newblocks/fields/field_asset.ts @@ -0,0 +1,582 @@ +/// + +import * as Blockly from "blockly"; + +import svg = pxt.svgUtil; +import { FieldBase } from "./field_base"; +import { getTemporaryAssets, getTilesReferencedByTilesets, setMelodyEditorOpen, workspaceToScreenCoordinates, bitmapToImageURI, tilemapToImageURI, songToDataURI, setBlockDataForField } from "./field_utils"; + +export interface FieldAssetEditorOptions { + initWidth?: string; + initHeight?: string; + + disableResize?: string; +} + +interface ParsedFieldAssetEditorOptions { + initWidth?: number; + initHeight?: number; + disableResize?: boolean; + lightMode?: boolean; +} + +// 32 is specifically chosen so that we can scale the images for the default +// sprite sizes without getting browser anti-aliasing +const PREVIEW_WIDTH = 32; +const X_PADDING = 5; +const Y_PADDING = 1; +const BG_PADDING = 4; +const BG_WIDTH = BG_PADDING * 2 + PREVIEW_WIDTH; +const TOTAL_HEIGHT = Y_PADDING * 2 + BG_PADDING * 2 + PREVIEW_WIDTH; +const TOTAL_WIDTH = X_PADDING * 2 + BG_PADDING * 2 + PREVIEW_WIDTH; + +export abstract class FieldAssetEditor extends FieldBase { + protected asset: pxt.Asset; + protected params: V; + + protected blocksInfo: pxtc.BlocksInfo; + protected lightMode: boolean; + protected undoRedoState: any; + protected pendingEdit = false; + protected isEmpty = false; + + // If input is invalid, the subclass can set this to be true. The field will instead + // render as a grey block and preserve the decompiled code + public isGreyBlock: boolean; + + constructor(text: string, params: any, validator?: Blockly.FieldValidator) { + super(text, params, validator); + + this.lightMode = params.lightMode; + this.params = this.parseFieldOptions(params); + this.blocksInfo = params.blocksInfo; + } + + protected abstract getAssetType(): pxt.AssetType; + protected abstract createNewAsset(text?: string): pxt.Asset; + protected abstract getValueText(): string; + + onInit() { + this.redrawPreview(); + } + + onValueChanged(newValue: string) { + this.parseValueText(newValue); + this.redrawPreview(); + return this.getValueText(); + } + + showEditor_() { + if (this.isGreyBlock) return; + + const params: any = {...this.params}; + + params.blocksInfo = this.blocksInfo; + + let editorKind: string; + + switch (this.asset.type) { + case pxt.AssetType.Tile: + case pxt.AssetType.Image: + editorKind = "image-editor"; + params.temporaryAssets = getTemporaryAssets(this.sourceBlock_.workspace, pxt.AssetType.Image); + break; + case pxt.AssetType.Animation: + editorKind = "animation-editor"; + params.temporaryAssets = getTemporaryAssets(this.sourceBlock_.workspace, pxt.AssetType.Image) + .concat(getTemporaryAssets(this.sourceBlock_.workspace, pxt.AssetType.Animation)); + break; + case pxt.AssetType.Tilemap: + editorKind = "tilemap-editor"; + const project = pxt.react.getTilemapProject(); + pxt.sprite.addMissingTilemapTilesAndReferences(project, this.asset); + + for (const tile of getTilesReferencedByTilesets(this.sourceBlock_.workspace)) { + if (this.asset.data.projectReferences.indexOf(tile.id) === -1) { + this.asset.data.projectReferences.push(tile.id); + } + } + break; + case pxt.AssetType.Song: + editorKind = "music-editor"; + params.temporaryAssets = getTemporaryAssets(this.sourceBlock_.workspace, pxt.AssetType.Song); + setMelodyEditorOpen(this.sourceBlock_, true); + break; + } + + + if (this.isFullscreen()) { + this.showEditorFullscreen(editorKind, params); + } + else { + this.showEditorInWidgetDiv(editorKind, params); + } + } + + protected showEditorFullscreen(editorKind: string, params: any) { + const fv = pxt.react.getFieldEditorView(editorKind, this.asset, params); + + if (this.undoRedoState) { + fv.restorePersistentData(this.undoRedoState); + } + + pxt.react.getTilemapProject().pushUndo(); + + fv.onHide(() => { + this.onFieldEditorHide(fv); + }); + + fv.show(); + } + + protected showEditorInWidgetDiv(editorKind: string, params: any) { + let bbox: Blockly.utils.Rect; + + // This is due to the changes in https://github.com/microsoft/pxt-blockly/pull/289 + // which caused the widgetdiv to jump around if any fields underneath changed size + let widgetOwner = { + getScaledBBox: () => bbox + } + + Blockly.WidgetDiv.show(widgetOwner, this.sourceBlock_.RTL, () => { + if (document.activeElement && document.activeElement.tagName === "INPUT") (document.activeElement as HTMLInputElement).blur(); + + fv.hide(); + + widgetDiv.classList.remove("sound-effect-editor-widget"); + widgetDiv.style.transform = ""; + widgetDiv.style.position = ""; + widgetDiv.style.left = ""; + widgetDiv.style.top = ""; + widgetDiv.style.width = ""; + widgetDiv.style.height = ""; + widgetDiv.style.opacity = ""; + widgetDiv.style.transition = ""; + widgetDiv.style.alignItems = ""; + + this.onFieldEditorHide(fv); + }) + + const widgetDiv = Blockly.WidgetDiv.getDiv(); + + const fv = pxt.react.getFieldEditorView(editorKind, this.asset, params, widgetDiv); + + const block = this.sourceBlock_ as Blockly.BlockSvg; + const bounds = block.getBoundingRectangle(); + const coord = workspaceToScreenCoordinates(block.workspace as Blockly.WorkspaceSvg, + new Blockly.utils.Coordinate(bounds.right, bounds.top)); + + const animationDistance = 20; + + const left = coord.x - 400; + const top = coord.y + 60 - animationDistance; + widgetDiv.style.opacity = "0"; + widgetDiv.classList.add("sound-effect-editor-widget"); + widgetDiv.style.position = "absolute"; + widgetDiv.style.left = left + "px"; + widgetDiv.style.top = top + "px"; + widgetDiv.style.width = "50rem"; + widgetDiv.style.height = "34.25rem"; + widgetDiv.style.display = "flex"; + widgetDiv.style.alignItems = "center"; + widgetDiv.style.transition = "transform 0.25s ease 0s, opacity 0.25s ease 0s"; + widgetDiv.style.borderRadius = ""; + + fv.onHide(() => { + Blockly.WidgetDiv.hideIfOwner(widgetOwner); + }); + + fv.show(); + + const divBounds = widgetDiv.getBoundingClientRect(); + const injectDivBounds = block.workspace.getInjectionDiv().getBoundingClientRect(); + + if (divBounds.height > injectDivBounds.height) { + widgetDiv.style.height = ""; + widgetDiv.style.top = `calc(1rem - ${animationDistance}px)`; + widgetDiv.style.bottom = `calc(1rem + ${animationDistance}px)`; + } + else { + if (divBounds.bottom > injectDivBounds.bottom || divBounds.top < injectDivBounds.top) { + // This editor is pretty tall, so just center vertically on the inject div + widgetDiv.style.top = (injectDivBounds.top + (injectDivBounds.height / 2) - (divBounds.height / 2)) - animationDistance + "px"; + } + } + + const toolboxWidth = block.workspace.getToolbox().getWidth(); + const workspaceLeft = injectDivBounds.left + toolboxWidth; + + if (divBounds.width > injectDivBounds.width - toolboxWidth) { + widgetDiv.style.width = ""; + widgetDiv.style.left = "1rem"; + widgetDiv.style.right = "1rem"; + } + else { + // Check to see if we are bleeding off the right side of the canvas + if (divBounds.left + divBounds.width >= injectDivBounds.right) { + // If so, try and place to the left of the block instead of the right + const blockLeft = workspaceToScreenCoordinates(block.workspace as Blockly.WorkspaceSvg, + new Blockly.utils.Coordinate(bounds.left, bounds.top)); + + if (blockLeft.x - divBounds.width - 20 > workspaceLeft) { + widgetDiv.style.left = (blockLeft.x - divBounds.width - 20) + "px" + } + else { + // As a last resort, just center on the inject div + widgetDiv.style.left = (workspaceLeft + ((injectDivBounds.width - toolboxWidth) / 2) - divBounds.width / 2) + "px"; + } + } + else if (divBounds.left < injectDivBounds.left) { + widgetDiv.style.left = workspaceLeft + "px" + } + } + + const finalDimensions = widgetDiv.getBoundingClientRect(); + bbox = new Blockly.utils.Rect(finalDimensions.top, finalDimensions.bottom, finalDimensions.left, finalDimensions.right); + + requestAnimationFrame(() => { + widgetDiv.style.opacity = "1"; + widgetDiv.style.transform = `translateY(${animationDistance}px)`; + }) + } + + protected onFieldEditorHide(fv: pxt.react.FieldEditorView) { + const result = fv.getResult(); + const project = pxt.react.getTilemapProject(); + + if (this.asset.type === pxt.AssetType.Song) { + setMelodyEditorOpen(this.sourceBlock_, false); + } + + if (result) { + const old = this.getValue(); + if (pxt.assetEquals(this.asset, result)) return; + + const oldId = isTemporaryAsset(this.asset) ? null : this.asset.id; + let newId = isTemporaryAsset(result) ? null : result.id; + + if (!oldId && newId === this.sourceBlock_.id) { + // The temporary assets we create just use the block id as the id; give it something + // a little nicer + result.id = project.generateNewID(result.type); + newId = result.id; + } + + this.pendingEdit = true; + + if (result.meta?.displayName) this.disposeOfTemporaryAsset(); + this.asset = result; + const lastRevision = project.revision(); + + this.onEditorClose(this.asset); + this.updateAssetListener(); + this.updateAssetMeta(); + this.redrawPreview(); + + this.undoRedoState = fv.getPersistentData(); + + if (this.sourceBlock_ && Blockly.Events.isEnabled()) { + const event = new BlocklyTilemapChange( + this.sourceBlock_, 'field', this.name, old, this.getValue(), lastRevision, project.revision()); + + if (oldId !== newId) { + event.oldAssetId = oldId; + event.newAssetId = newId; + } + + Blockly.Events.fire(event); + } + this.pendingEdit = false; + } + } + + render_() { + if (this.isGreyBlock && !this.textElement_) { + this.createTextElement_(); + } + super.render_(); + + if (!this.isGreyBlock) { + this.size_.height = TOTAL_HEIGHT; + this.size_.width = TOTAL_WIDTH; + } + } + + getDisplayText_() { + // This is only used when isGreyBlock is true + if (this.isGreyBlock) { + const text = pxt.Util.htmlUnescape(this.valueText); + return text.substr(0, text.indexOf("(")) + "(...)"; + } + return ""; + } + + updateEditable() { + if (this.isGreyBlock && this.fieldGroup_) { + const group = this.fieldGroup_; + Blockly.utils.dom.removeClass(group, 'blocklyNonEditableText'); + Blockly.utils.dom.removeClass(group, 'blocklyEditableText'); + group.style.cursor = ''; + } + else { + super.updateEditable(); + } + } + + getValue() { + if (this.isGreyBlock) return pxt.Util.htmlUnescape(this.valueText); + + return this.getValueText(); + } + + onDispose() { + if (this.sourceBlock_?.workspace && !this.sourceBlock_.workspace.rendered) { + this.disposeOfTemporaryAsset(); + } + pxt.react.getTilemapProject().removeChangeListener(this.getAssetType(), this.assetChangeListener); + } + + disposeOfTemporaryAsset() { + if (this.isTemporaryAsset()) { + pxt.react.getTilemapProject().removeAsset(this.asset); + this.setBlockData(null); + this.asset = undefined; + } + } + + clearTemporaryAssetData() { + if (this.isTemporaryAsset()) { + this.setBlockData(null); + } + } + + isTemporaryAsset() { + return isTemporaryAsset(this.asset); + } + + getAsset() { + return this.asset; + } + + updateAsset(asset: pxt.Asset) { + this.asset = asset; + this.setValue(this.getValue()); + } + + protected onEditorClose(newValue: pxt.Asset) { + // Subclass + } + + protected redrawPreview() { + if (!this.fieldGroup_) return; + pxsim.U.clear(this.fieldGroup_); + + if (this.isGreyBlock) { + this.createTextElement_(); + this.render_(); + this.updateEditable(); + return; + } + + const bg = new svg.Rect() + .at(X_PADDING, Y_PADDING) + .size(BG_WIDTH, BG_WIDTH) + .setClass("blocklySpriteField") + .stroke("#898989", 1) + .corner(4); + + this.fieldGroup_.appendChild(bg.el); + + if (this.asset) { + let dataURI: string; + switch (this.asset.type) { + case pxt.AssetType.Image: + case pxt.AssetType.Tile: + dataURI = bitmapToImageURI(pxt.sprite.Bitmap.fromData(this.asset.bitmap), PREVIEW_WIDTH, this.lightMode); + break; + case pxt.AssetType.Animation: + dataURI = bitmapToImageURI(pxt.sprite.Bitmap.fromData(this.asset.frames[0]), PREVIEW_WIDTH, this.lightMode); + break; + case pxt.AssetType.Tilemap: + dataURI = tilemapToImageURI(this.asset.data, PREVIEW_WIDTH, this.lightMode); + break; + case pxt.AssetType.Song: + dataURI = songToDataURI(this.asset.song, 60, 20, this.lightMode); + break; + } + + if (dataURI) { + const img = new svg.Image() + .src(dataURI) + .at(X_PADDING + BG_PADDING, Y_PADDING + BG_PADDING) + .size(PREVIEW_WIDTH, PREVIEW_WIDTH); + this.fieldGroup_.appendChild(img.el); + } + } + } + + protected parseValueText(newText: string) { + newText = pxt.Util.htmlUnescape(newText); + if (this.sourceBlock_ && !this.sourceBlock_.isInFlyout) { + const project = pxt.react.getTilemapProject(); + + const id = this.getBlockData(); + const existing = project.lookupAsset(this.getAssetType(), id); + if (existing && !(newText && this.isEmpty)) { + this.asset = existing; + } + else { + this.setBlockData(null); + if (this.asset) { + if (this.sourceBlock_ && this.asset.meta.blockIDs) { + this.asset.meta.blockIDs = this.asset.meta.blockIDs.filter(id => id !== this.sourceBlock_.id); + + if (!this.isTemporaryAsset()) { + project.updateAsset(this.asset); + } + } + } + this.isEmpty = !newText; + this.asset = this.createNewAsset(newText); + } + this.updateAssetMeta(); + this.updateAssetListener(); + } + } + + protected parseFieldOptions(opts: U): V { + const parsed: ParsedFieldAssetEditorOptions = { + initWidth: 16, + initHeight: 16, + disableResize: false, + lightMode: false + }; + + if (!opts) { + return parsed as V; + } + + if (opts.disableResize) { + parsed.disableResize = opts.disableResize.toLowerCase() === "true" || opts.disableResize === "1"; + } + + parsed.initWidth = withDefault(opts.initWidth, parsed.initWidth); + parsed.initHeight = withDefault(opts.initHeight, parsed.initHeight); + parsed.lightMode = (opts as any).lightMode; + + return parsed as V; + + function withDefault(raw: string, def: number) { + const res = parseInt(raw); + if (isNaN(res)) { + return def; + } + return res; + } + } + + protected updateAssetMeta() { + if (!this.asset) return; + + if (!this.asset.meta) { + this.asset.meta = {}; + } + + if (!this.asset.meta.blockIDs) { + this.asset.meta.blockIDs = []; + } + + if (this.sourceBlock_) { + if (this.asset.meta.blockIDs.indexOf(this.sourceBlock_.id) === -1) { + const blockIDs = this.asset.meta.blockIDs; + if (blockIDs.length && this.isTemporaryAsset() && blockIDs.some(id => this.sourceBlock_.workspace.getBlockById(id))) { + // This temporary asset is already used, so we should clone a copy for ourselves + this.asset = pxt.cloneAsset(this.asset) + this.asset.meta.blockIDs = []; + } + this.asset.meta.blockIDs.push(this.sourceBlock_.id); + } + this.setBlockData(this.isTemporaryAsset() ? null : this.asset.id); + } + + if (!this.isTemporaryAsset()) { + pxt.react.getTilemapProject().updateAsset(this.asset); + } + else { + this.asset.meta.temporaryInfo = { + blockId: this.sourceBlock_.id, + fieldName: this.name + }; + } + } + + protected updateAssetListener() { + pxt.react.getTilemapProject().removeChangeListener(this.getAssetType(), this.assetChangeListener); + if (this.asset && !this.isTemporaryAsset()) { + pxt.react.getTilemapProject().addChangeListener(this.asset, this.assetChangeListener); + } + } + + protected assetChangeListener = () => { + if (this.pendingEdit) return; + const id = this.getBlockData(); + if (id) { + this.asset = pxt.react.getTilemapProject().lookupAsset(this.getAssetType(), id); + } + this.redrawPreview(); + } + + protected isFullscreen() { + return true; + } +} + +function isTemporaryAsset(asset: pxt.Asset) { + return asset && !asset.meta.displayName; +} + +export class BlocklyTilemapChange extends Blockly.Events.BlockChange { + oldAssetId: string; + newAssetId: string; + fieldName: string; + + constructor(block: Blockly.Block, element: string, name: string, oldValue: any, newValue: any, protected oldRevision: number, protected newRevision: number) { + super(block, element, name, oldValue, newValue); + this.fieldName = name; + } + + isNull() { + return this.oldRevision === this.newRevision && super.isNull(); + } + + run(forward: boolean) { + if (this.newAssetId || this.oldAssetId) { + const block = this.getEventWorkspace_().getBlockById(this.blockId); + + if (forward) { + setBlockDataForField(block, this.fieldName, this.newAssetId); + } + else { + setBlockDataForField(block, this.fieldName, this.oldAssetId); + } + } + + if (forward) { + pxt.react.getTilemapProject().redo(); + super.run(forward); + } + else { + pxt.react.getTilemapProject().undo(); + super.run(forward); + } + + const ws = this.getEventWorkspace_(); + + // Fire an event to force a recompile, but make sure it doesn't end up on the undo stack + const ev = new BlocklyTilemapChange( + ws.getBlockById(this.blockId), 'tilemap-revision', "revision", null, pxt.react.getTilemapProject().revision(), 0, 0); + ev.recordUndo = false; + + Blockly.Events.fire(ev) + } +} \ No newline at end of file diff --git a/newblocks/fields/field_autocomplete.ts b/newblocks/fields/field_autocomplete.ts new file mode 100644 index 000000000000..8980444fa079 --- /dev/null +++ b/newblocks/fields/field_autocomplete.ts @@ -0,0 +1,193 @@ +// /// + +// import * as Blockly from "blockly"; +// import { FieldCustom, FieldCustomOptions } from "./field_utils"; + +// // namespace Blockly { +// // export interface FieldTextDropdown { +// // showDropdown_(): void +// // isTextValid_: boolean; +// // } +// // } + +// export interface FieldAutoCompleteOptions extends FieldCustomOptions { +// // This is a unique key that should be specified by the parent block. The dropdown +// // will only be populated by other blocks with this same key. If not specified, the parent's +// // block type will be used +// key: string; +// } + +// export class FieldAutoComplete extends Blockly.FieldTextDropdown implements FieldCustom { +// public isFieldCustom_ = true; +// protected key: string; +// protected parsedValue: string; + +// protected quoteSize_: number; +// protected quoteWidth_: number; +// protected quoteLeftX_: number; +// protected quoteRightX_: number; +// protected quoteY_: number; +// protected quoteLeft_: SVGTextElement; +// protected quoteRight_: SVGTextElement; + +// constructor(text: string, options: FieldAutoCompleteOptions, opt_validator?: Function) { +// super(text, () => [] as any, opt_validator); +// this.key = options.key; +// this.isTextValid_ = true; +// } + +// isOptionListDynamic() { +// return true; +// } + +// getDisplayText_(): string { +// return this.parsedValue || ""; +// } + +// doValueUpdate_(newValue: string) { +// if (newValue === null) return; + +// if (/['"`].*['"`]/.test(newValue)) { +// this.parsedValue = JSON.parse(newValue) +// } +// else { +// this.parsedValue = newValue; +// } + +// this.value_ = this.parsedValue +// } + +// getValue() { +// if (this.parsedValue) { +// return JSON.stringify(this.parsedValue) +// } +// else return '""'; +// } + +// getOptions() { +// const workspace = this.sourceBlock_?.workspace; + +// if (!workspace) return []; + +// const res: [string, string][] = []; +// const fields = getAllFields(workspace, field => field instanceof FieldAutoComplete && field.getKey() === this.key); + +// const options = fields.map(field => field.ref.getDisplayText_()); + +// for (const option of options) { +// if (!option.trim() || res.some(tuple => tuple[0] === option)) continue; +// res.push([option, option]) +// } + +// res.sort((a, b) => a[0].localeCompare(b[0])); + +// return res; +// } + +// showDropdown_() { +// const options = this.getOptions(); + +// if (options.length) super.showDropdown_() +// } + +// getKey() { +// if (this.key) return this.key; +// if (this.sourceBlock_) return this.sourceBlock_.type; + +// return undefined; +// } + +// // Copied from field_string in pxt-blockly +// initView(): void { +// // Add quotes around the string +// // Positioned on updatSize, after text size is calculated. +// this.quoteSize_ = 16; +// this.quoteWidth_ = 8; +// this.quoteLeftX_ = 0; +// this.quoteRightX_ = 0; +// this.quoteY_ = 10; +// if (this.quoteLeft_) this.quoteLeft_.parentNode.removeChild(this.quoteLeft_); +// this.quoteLeft_ = Blockly.utils.dom.createSvgElement('text', { +// 'font-size': this.quoteSize_ + 'px', +// 'class': 'field-text-quote' +// }, this.fieldGroup_); + +// super.initView(); + +// if (this.quoteRight_) this.quoteRight_.parentNode.removeChild(this.quoteRight_); +// this.quoteRight_ = Blockly.utils.dom.createSvgElement('text', { +// 'font-size': this.quoteSize_ + 'px', +// 'class': 'field-text-quote' +// }, this.fieldGroup_); +// this.quoteLeft_.appendChild(document.createTextNode('"')); +// this.quoteRight_.appendChild(document.createTextNode('"')); +// } + +// // Copied from field_string in pxt-blockly +// updateSize_(): void { +// super.updateSize_(); + +// const sWidth = Math.max(this.size_.width, 1); + +// const xPadding = 3; +// let addedWidth = this.positionLeft(sWidth + xPadding); +// this.textElement_.setAttribute('x', addedWidth.toString()); +// addedWidth += this.positionRight(addedWidth + sWidth + xPadding); + +// this.size_.width = sWidth + addedWidth; +// } + +// // Copied from field_string in pxt-blockly +// positionRight(x: number) { +// if (!this.quoteRight_) { +// return 0; +// } +// let addedWidth = 0; +// if (this.sourceBlock_.RTL) { +// this.quoteRightX_ = Blockly.FieldString.quotePadding; +// addedWidth = this.quoteWidth_ + Blockly.FieldString.quotePadding; +// } else { +// this.quoteRightX_ = x + Blockly.FieldString.quotePadding; +// addedWidth = this.quoteWidth_ + Blockly.FieldString.quotePadding; +// } +// this.quoteRight_.setAttribute('transform', +// 'translate(' + this.quoteRightX_ + ',' + this.quoteY_ + ')' +// ); +// return addedWidth; +// } + +// // Copied from field_string in pxt-blockly +// positionLeft(x: number) { +// if (!this.quoteLeft_) { +// return 0; +// } +// let addedWidth = 0; +// if (this.sourceBlock_.RTL) { +// this.quoteLeftX_ = x + this.quoteWidth_ + Blockly.FieldString.quotePadding * 2; +// addedWidth = this.quoteWidth_ + Blockly.FieldString.quotePadding; +// } else { +// this.quoteLeftX_ = 0; +// addedWidth = this.quoteWidth_ + Blockly.FieldString.quotePadding; +// } +// this.quoteLeft_.setAttribute('transform', +// 'translate(' + this.quoteLeftX_ + ',' + this.quoteY_ + ')' +// ); +// return addedWidth; +// } + +// createSVGArrow_() { +// // This creates the little arrow for dropdown fields. Intentionally +// // do nothing +// } + +// showPromptEditor_() { +// Blockly.prompt( +// Blockly.Msg['CHANGE_VALUE_TITLE'], +// this.parsedValue, +// (newValue) => { +// this.setValue(this.getValueFromEditorText_(newValue)); +// this.forceRerender(); +// } +// ); +// } +// } \ No newline at end of file diff --git a/newblocks/fields/field_base.ts b/newblocks/fields/field_base.ts new file mode 100644 index 000000000000..3260f24f1c89 --- /dev/null +++ b/newblocks/fields/field_base.ts @@ -0,0 +1,94 @@ +/// + +import * as Blockly from "blockly"; +import { FieldCustom, getBlockDataForField, setBlockDataForField } from "./field_utils"; + +export abstract class FieldBase extends Blockly.Field implements FieldCustom { + isFieldCustom_: true; + SERIALIZABLE = true; + options: U; + protected valueText: string; + protected loaded: boolean; + protected workspace: Blockly.Workspace; + + constructor(text: string, params: U, validator?: Blockly.FieldValidator) { + super(text, validator); + this.options = params; + if (text && !this.valueText) this.valueText = text; + } + + protected abstract onInit(): void; + protected abstract onDispose(): void; + protected abstract onValueChanged(newValue: string): string; + + init() { + super.init(); + this.onInit(); + } + + dispose() { + this.onDispose(); + } + + getValue() { + return this.valueText; + } + + doValueUpdate_(newValue: string) { + if (newValue === null) return; + + this.valueText = this.loaded ? this.onValueChanged(newValue) : newValue; + } + + getDisplayText_() { + return this.valueText; + } + + onLoadedIntoWorkspace() { + if (this.loaded) return; + this.loaded = true; + this.valueText = this.onValueChanged(this.valueText); + } + + protected getAnchorDimensions() { + const boundingBox = this.getScaledBBox() as any; + if (this.sourceBlock_.RTL) { + boundingBox.right += Blockly.FieldDropdown.CHECKMARK_OVERHANG; + } else { + boundingBox.left -= Blockly.FieldDropdown.CHECKMARK_OVERHANG; + } + return boundingBox; + }; + + protected isInitialized() { + return !!this.fieldGroup_; + } + + protected getBlockData() { + return getBlockDataForField(this.sourceBlock_, this.name); + } + + protected setBlockData(value: string) { + setBlockDataForField(this.sourceBlock_, this.name, value); + } + + protected getSiblingBlock(inputName: string, useGrandparent = false) { + const block = useGrandparent ? this.sourceBlock_.getParent() : this.sourceBlock_; + + if (!block || !block.inputList) return undefined; + + for (const input of block.inputList) { + if (input.name === inputName) { + return input.connection.targetBlock(); + } + } + + return undefined; + } + + protected getSiblingField(fieldName: string, useGrandparent = false) { + const block = useGrandparent ? this.sourceBlock_.getParent() : this.sourceBlock_; + if (!block) return undefined; + return block.getField(fieldName); + } +} \ No newline at end of file diff --git a/newblocks/fields/field_breakpoint.ts b/newblocks/fields/field_breakpoint.ts new file mode 100644 index 000000000000..31131e9ed659 --- /dev/null +++ b/newblocks/fields/field_breakpoint.ts @@ -0,0 +1,161 @@ +// /// + +// import * as Blockly from "blockly"; +// import { FieldCustom, FieldCustomOptions } from "./field_utils"; + +// export class FieldBreakpoint extends Blockly.Field implements FieldCustom { +// public isFieldCustom_ = true; + +// private params: any; + +// private state_: boolean; +// private checkElement_: SVGElement; + +// private toggleThumb_: SVGElement; + +// public CURSOR = 'pointer'; + +// private type_: string; + +// constructor(state: string, params: FieldCustomOptions, opt_validator?: Blockly.FieldValidator) { +// super(state, opt_validator); +// this.params = params; +// this.setValue(state); +// this.addArgType('toggle'); +// this.type_ = params.type; +// } + +// initView() { +// if (!this.fieldGroup_) { +// return; +// } + +// // Add an attribute to cassify the type of field. +// if ((this as any).getArgTypes() !== null) { +// if (this.sourceBlock_.isShadow()) { +// (this.sourceBlock_ as any).svgGroup_.setAttribute('data-argument-type', +// (this as any).getArgTypes()); +// } else { +// // Fields without a shadow wrapper, like square dropdowns. +// this.fieldGroup_.setAttribute('data-argument-type', (this as any).getArgTypes()); +// } +// } +// // Adjust X to be flipped for RTL. Position is relative to horizontal start of source block. +// const size = this.getSize(); +// this.checkElement_ = Blockly.utils.dom.createSvgElement('g', +// { +// 'class': `blocklyToggle ${this.state_ ? 'blocklyToggleOnBreakpoint' : 'blocklyToggleOffBreakpoint'}`, +// 'transform': `translate(8, ${size.height / 2})`, +// }, this.fieldGroup_); +// this.toggleThumb_ = Blockly.utils.dom.createSvgElement('polygon', +// { +// 'class': 'blocklyToggleRect', +// 'points': '50,5 100,5 125,30 125,80 100,105 50,105 25,80 25,30' +// }, +// this.checkElement_); + +// let fieldX = (this.sourceBlock_.RTL) ? -size.width / 2 : size.width / 2; +// /** @type {!Element} */ +// this.textElement_ = Blockly.utils.dom.createSvgElement('text', +// { +// 'class': 'blocklyText', +// 'x': fieldX, +// 'dy': '0.6ex', +// 'y': size.height / 2 +// }, +// this.fieldGroup_) as SVGTextElement; + +// this.switchToggle(this.state_); +// this.setValue(this.getValue()); + +// // Force a render. +// this.markDirty(); +// } + +// updateSize_() { +// this.size_.width = 30; +// } + +// /** +// * Return 'TRUE' if the toggle is ON, 'FALSE' otherwise. +// * @return {string} Current state. +// */ +// getValue() { +// return this.toVal(this.state_); +// }; + +// /** +// * Set the checkbox to be checked if newBool is 'TRUE' or true, +// * unchecks otherwise. +// * @param {string|boolean} newBool New state. +// */ +// setValue(newBool: string) { +// let newState = this.fromVal(newBool); +// if (this.state_ !== newState) { +// if (this.sourceBlock_ && Blockly.Events.isEnabled()) { +// Blockly.Events.fire(new (Blockly.Events as any).BlockChange( +// this.sourceBlock_, 'field', this.name, this.state_, newState)); +// } +// this.state_ = newState; + +// this.switchToggle(this.state_); +// } +// } + +// switchToggle(newState: boolean) { +// if (this.checkElement_) { +// this.updateSize_(); +// if (newState) { +// pxt.BrowserUtils.addClass(this.checkElement_, 'blocklyToggleOnBreakpoint'); +// pxt.BrowserUtils.removeClass(this.checkElement_, 'blocklyToggleOffBreakpoint'); +// } else { +// pxt.BrowserUtils.removeClass(this.checkElement_, 'blocklyToggleOnBreakpoint'); +// pxt.BrowserUtils.addClass(this.checkElement_, 'blocklyToggleOffBreakpoint'); +// } +// this.checkElement_.setAttribute('transform', `translate(-7, -1) scale(0.3)`); +// } +// } + +// updateDisplay_(newValue: string) { +// super.updateDisplay_(newValue); +// if (this.textElement_) +// pxt.BrowserUtils.addClass(this.textElement_ as SVGElement, 'blocklyToggleText'); +// } + +// render_() { +// if (this.visible_ && this.textElement_) { +// // Replace the text. +// goog.dom.removeChildren(/** @type {!Element} */(this.textElement_)); +// this.updateSize_(); +// } +// } + +// /** +// * Toggle the state of the toggle. +// * @private +// */ +// showEditor_() { +// let newState = !this.state_; +// /* +// if (this.sourceBlock_) { +// // Call any validation function, and allow it to override. +// newState = this.callValidator(newState); +// }*/ +// if (newState !== null) { +// this.setValue(this.toVal(newState)); +// } +// } + +// private toVal(newState: boolean): string { +// if (this.type_ == "number") return String(newState ? '1' : '0'); +// else return String(newState ? 'true' : 'false'); +// } + +// private fromVal(val: string): boolean { +// if (typeof val == "string") { +// if (val == "1" || val.toUpperCase() == "TRUE") return true; +// return false; +// } +// return !!val; +// } +// } \ No newline at end of file diff --git a/newblocks/fields/field_colorwheel.ts b/newblocks/fields/field_colorwheel.ts new file mode 100644 index 000000000000..02cf938120cb --- /dev/null +++ b/newblocks/fields/field_colorwheel.ts @@ -0,0 +1,145 @@ +// /// + +// import * as Blockly from "blockly"; + +// namespace pxtblockly { + +// export class FieldColorWheel extends Blockly.FieldSlider implements Blockly.FieldCustom { +// public isFieldCustom_ = true; + +// private params: any; + +// private channel_: string; + +// /** +// * Class for a color wheel field. +// * @param {number|string} value The initial content of the field. +// * @param {Function=} opt_validator An optional function that is called +// * to validate any constraints on what the user entered. Takes the new +// * text as an argument and returns either the accepted text, a replacement +// * text, or null to abort the change. +// * @extends {Blockly.FieldNumber} +// * @constructor +// */ +// constructor(value_: any, params: any, opt_validator?: Function) { +// super(String(value_), '0', '255', '1', '10', 'Color', opt_validator); +// this.params = params; +// if (this.params['min']) this.min_ = parseFloat(this.params['min']); +// if (this.params['max']) this.max_ = parseFloat(this.params['max']); +// if (this.params['label']) this.labelText_ = this.params['label']; +// if (this.params['channel']) this.channel_ = this.params['channel']; +// } + +// /** +// * Set the gradient CSS properties for the given node and channel +// * @param {Node} node - The DOM node the gradient will be set on. +// * @private +// */ +// setBackground_(node: Element) { +// let gradient = this.createColourStops_().join(','); +// goog.style.setStyle(node, 'background', +// '-moz-linear-gradient(left, ' + gradient + ')'); +// goog.style.setStyle(node, 'background', +// '-webkit-linear-gradient(left, ' + gradient + ')'); +// goog.style.setStyle(node, 'background', +// '-o-linear-gradient(left, ' + gradient + ')'); +// goog.style.setStyle(node, 'background', +// '-ms-linear-gradient(left, ' + gradient + ')'); +// goog.style.setStyle(node, 'background', +// 'linear-gradient(left, ' + gradient + ')'); +// if (this.params['sliderWidth']) +// goog.style.setStyle(node, 'width', +// `${this.params['sliderWidth']}px`) +// }; + +// setReadout_(readout: Element, value: string) { +// const hexValue = this.colorWheel(parseInt(value), this.channel_); +// // +// const readoutSpan = document.createElement('span'); +// readoutSpan.className = "blocklyColorReadout"; +// readoutSpan.style.backgroundColor = `${hexValue}`; + +// pxsim.U.clear(readout); +// readout.appendChild(readoutSpan); +// } + +// createColourStops_() { +// let stops: string[] = []; +// for (let n = 0; n <= 255; n += 20) { +// stops.push(this.colorWheel(n, this.channel_)); +// } +// return stops; +// }; + +// colorWheel(wheelPos: number, channel?: string): string { +// if (channel == "hsvfast") { +// return this.hsvFast(wheelPos, 255, 255); +// } else { +// wheelPos = 255 - wheelPos; +// if (wheelPos < 85) { +// return this.hex(wheelPos * 3, 255, 255 - wheelPos * 3); +// } +// if (wheelPos < 170) { +// wheelPos -= 85; +// return this.hex(255, 255 - wheelPos * 3, wheelPos * 3); +// } +// wheelPos -= 170; +// return this.hex(255 - wheelPos * 3, wheelPos * 3, 255); +// } +// } + +// hsvFast(hue: number, sat: number, val: number): string { +// let h = (hue % 255) >> 0; +// if (h < 0) h += 255; +// // scale down to 0..192 +// h = (h * 192 / 255) >> 0; + +// //reference: based on FastLED's hsv2rgb rainbow algorithm [https://github.com/FastLED/FastLED](MIT) +// let invsat = 255 - sat; +// let brightness_floor = ((val * invsat) / 255) >> 0; +// let color_amplitude = val - brightness_floor; +// let section = (h / 0x40) >> 0; // [0..2] +// let offset = (h % 0x40) >> 0; // [0..63] + +// let rampup = offset; +// let rampdown = (0x40 - 1) - offset; + +// let rampup_amp_adj = ((rampup * color_amplitude) / (255 / 4)) >> 0; +// let rampdown_amp_adj = ((rampdown * color_amplitude) / (255 / 4)) >> 0; + +// let rampup_adj_with_floor = (rampup_amp_adj + brightness_floor); +// let rampdown_adj_with_floor = (rampdown_amp_adj + brightness_floor); + +// let r: number; +// let g: number; +// let b: number; +// if (section) { +// if (section == 1) { +// // section 1: 0x40..0x7F +// r = brightness_floor; +// g = rampdown_adj_with_floor; +// b = rampup_adj_with_floor; +// } else { +// // section 2; 0x80..0xBF +// r = rampup_adj_with_floor; +// g = brightness_floor; +// b = rampdown_adj_with_floor; +// } +// } else { +// // section 0: 0x00..0x3F +// r = rampdown_adj_with_floor; +// g = rampup_adj_with_floor; +// b = brightness_floor; +// } +// return this.hex(r, g, b); +// } + +// private hex(red: number, green: number, blue: number): string { +// return `#${this.componentToHex(red & 0xFF)}${this.componentToHex(green & 0xFF)}${this.componentToHex(blue & 0xFF)}`; +// } +// private componentToHex(c: number) { +// let hex = c.toString(16); +// return hex.length == 1 ? "0" + hex : hex; +// } +// } +// } diff --git a/newblocks/fields/field_colour.ts b/newblocks/fields/field_colour.ts new file mode 100644 index 000000000000..f08d105a8185 --- /dev/null +++ b/newblocks/fields/field_colour.ts @@ -0,0 +1,164 @@ +// /// + +// import * as Blockly from "blockly"; +// import { FieldCustom, FieldCustomOptions } from "./field_utils"; + +// /** +// * The value modes: +// * hex - Outputs an HTML color string: "#ffffff" (with quotes) +// * rgb - Outputs an RGB number in hex: 0xffffff +// * index - Outputs the index of the color in the list of colors: 0 +// */ +// export type FieldColourValueMode = "hex" | "rgb" | "index"; + +// export interface FieldColourNumberOptions extends FieldCustomOptions { +// colours?: string; // parsed as a JSON array +// columns?: string; // parsed as a number +// className?: string; +// valueMode?: FieldColourValueMode; +// } + +// export class FieldColorNumber extends Blockly.FieldColour implements FieldCustom { +// public isFieldCustom_ = true; + +// protected colour_: string; +// private valueMode_: FieldColourValueMode = "rgb"; + +// constructor(text: string, params: FieldColourNumberOptions, opt_validator?: Blockly.FieldValidator) { +// super(text, opt_validator); + +// if (params.colours) +// this.setColours(JSON.parse(params.colours)); +// else if (pxt.appTarget.runtime && pxt.appTarget.runtime.palette) { +// let p = pxt.Util.clone(pxt.appTarget.runtime.palette); +// p[0] = "#dedede"; +// let t; +// if (pxt.appTarget.runtime.paletteNames) { +// t = pxt.Util.clone(pxt.appTarget.runtime.paletteNames); +// t[0] = lf("transparent"); +// } +// this.setColours(p, t); +// } + +// // Set to first color in palette (for toolbox) +// this.setValue(this.getColours_()[0]); + +// if (params.columns) this.setColumns(parseInt(params.columns)); +// if (params.className) this.className_ = params.className; +// if (params.valueMode) this.valueMode_ = params.valueMode; +// } + +// /** +// * @override +// */ +// applyColour() { +// if (this.borderRect_) { +// this.borderRect_.style.fill = this.value_; +// } else if (this.sourceBlock_) { +// (this.sourceBlock_ as any)?.pathObject?.svgPath?.setAttribute('fill', this.value_); +// (this.sourceBlock_ as any)?.pathObject?.svgPath?.setAttribute('stroke', '#fff'); +// } +// }; + +// doClassValidation_(colour: string) { +// return "string" != typeof colour ? null : parseColour(colour, this.getColours_()); +// } + +// /** +// * Return the current colour. +// * @param {boolean} opt_asHex optional field if the returned value should be a hex +// * @return {string} Current colour in '#rrggbb' format. +// */ +// getValue(opt_asHex?: boolean) { +// if (opt_asHex) return this.value_; +// switch (this.valueMode_) { +// case "hex": +// return `"${this.value_}"`; +// case "rgb": +// if (this.value_.indexOf('#') > -1) { +// return `0x${this.value_.replace(/^#/, '')}`; +// } +// else { +// return this.value_; +// } +// case "index": +// if (!this.value_) return "-1"; +// const allColours = this.getColours_(); +// for (let i = 0; i < allColours.length; i++) { +// if (this.value_.toUpperCase() === allColours[i].toUpperCase()) { +// return i + ""; +// } +// } +// } +// return this.value_; +// } + +// /** +// * Set the colour. +// * @param {string} colour The new colour in '#rrggbb' format. +// */ +// doValueUpdate_(colour: string) { +// this.value_ = parseColour(colour, this.getColours_()); +// this.applyColour(); +// } + +// showEditor_() { +// super.showEditor_(); +// if (this.className_ && this.picker_) +// pxt.BrowserUtils.addClass(this.picker_ as HTMLElement, this.className_); +// } + +// getColours_(): string[] { +// return this.colours_; +// } +// } + +// function parseColour(colour: string, allColours: string[]) { +// if (colour) { +// const enumSplit = /Colors\.([a-zA-Z]+)/.exec(colour); +// const hexSplit = /(0x|#)([0-9a-fA-F]+)/.exec(colour); + +// if (enumSplit) { +// switch (enumSplit[1].toLocaleLowerCase()) { +// case "red": return "#FF0000"; +// case "orange": return "#FF7F00"; +// case "yellow": return "#FFFF00"; +// case "green": return "#00FF00"; +// case "blue": return "#0000FF"; +// case "indigo": return "#4B0082"; +// case "violet": return "#8A2BE2"; +// case "purple": return "#A033E5"; +// case "pink": return "#FF007F"; +// case "white": return "#FFFFFF"; +// case "black": return "#000000"; +// default: return colour; +// } +// } else if (hexSplit) { +// const hexLiteralNumber = hexSplit[2]; + +// if (hexLiteralNumber.length === 3) { +// // if shorthand color, return standard hex triple +// let output = "#"; +// for (let i = 0; i < hexLiteralNumber.length; i++) { +// const digit = hexLiteralNumber.charAt(i); +// output += digit + digit; +// } +// return output; +// } else if (hexLiteralNumber.length === 6) { +// return "#" + hexLiteralNumber; +// } +// } + +// if (allColours) { +// const parsedAsInt = parseInt(colour); + +// // Might be the index and not the color +// if (!isNaN(parsedAsInt) && allColours[parsedAsInt] != undefined) { +// return allColours[parsedAsInt]; +// } else { +// return allColours[0]; +// } +// } +// } +// return colour; +// } \ No newline at end of file diff --git a/newblocks/fields/field_gridpicker.ts b/newblocks/fields/field_gridpicker.ts new file mode 100644 index 000000000000..663e354f5afe --- /dev/null +++ b/newblocks/fields/field_gridpicker.ts @@ -0,0 +1,650 @@ +// /// + +// import * as Blockly from "blockly"; + +// namespace pxtblockly { + +// export interface FieldGridPickerToolTipConfig { +// yOffset?: number; +// xOffset?: number; +// } + +// export interface FieldGridPickerOptions extends Blockly.FieldCustomDropdownOptions { +// columns?: string; +// maxRows?: string; +// width?: string; +// tooltips?: string; +// tooltipsXOffset?: string; +// tooltipsYOffset?: string; +// hasSearchBar?: boolean; +// hideRect?: boolean; +// } + +// export class FieldGridPicker extends Blockly.FieldDropdown implements Blockly.FieldCustom { +// public isFieldCustom_ = true; +// // Width in pixels +// private width_: number; + +// // Columns in grid +// private columns_: number; + +// // Number of rows to display (if there are extra rows, the picker will be scrollable) +// private maxRows_: number; + +// protected backgroundColour_: string; +// protected borderColour_: string; + +// private tooltipConfig_: FieldGridPickerToolTipConfig; + +// private gridTooltip_: HTMLElement; +// private firstItem_: HTMLElement; + +// private hasSearchBar_: boolean; +// private hideRect_: boolean; + +// private observer: IntersectionObserver; + +// private selectedItemDom: HTMLElement; + +// private closeModal_: boolean; + +// // Selected bar +// private selectedBar_: HTMLElement; +// private selectedImg_: HTMLImageElement; +// private selectedBarText_: HTMLElement; +// private selectedBarValue_: string; + +// private static DEFAULT_IMG = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; + +// constructor(text: string, options: FieldGridPickerOptions, validator?: Function) { +// super(options.data); + +// this.columns_ = parseInt(options.columns) || 4; +// this.maxRows_ = parseInt(options.maxRows) || 0; +// this.width_ = parseInt(options.width) || 200; + +// this.backgroundColour_ = pxtblockly.parseColour(options.colour); +// this.borderColour_ = pxt.toolbox.fadeColor(this.backgroundColour_, 0.4, false); + +// let tooltipCfg: FieldGridPickerToolTipConfig = { +// xOffset: parseInt(options.tooltipsXOffset) || 15, +// yOffset: parseInt(options.tooltipsYOffset) || -10 +// } + +// this.tooltipConfig_ = tooltipCfg; +// this.hasSearchBar_ = !!options.hasSearchBar || false; +// this.hideRect_ = !!options.hideRect || false; +// } + +// /** +// * When disposing the grid picker, make sure the tooltips are disposed too. +// * @public +// */ +// public dispose() { +// super.dispose(); +// this.disposeTooltip(); +// this.disposeIntersectionObserver(); +// } + +// private createTooltip_() { +// if (this.gridTooltip_) return; + +// // Create tooltip +// this.gridTooltip_ = document.createElement('div'); +// this.gridTooltip_.className = 'goog-tooltip blocklyGridPickerTooltip'; +// this.gridTooltip_.style.position = 'absolute'; +// this.gridTooltip_.style.display = 'none'; +// this.gridTooltip_.style.visibility = 'hidden'; +// document.body.appendChild(this.gridTooltip_); +// } + +// /** +// * Create blocklyGridPickerRows and add them to table container +// * @param options +// * @param tableContainer +// */ +// private populateTableContainer(options: (Object | String[])[], tableContainer: HTMLElement, scrollContainer: HTMLElement) { + +// pxsim.U.removeChildren(tableContainer); +// if (options.length == 0) { +// this.firstItem_ = undefined +// } + +// for (let i = 0; i < options.length / this.columns_; i++) { +// let row = this.populateRow(i, options, tableContainer); +// tableContainer.appendChild(row); +// } +// } + +// /** +// * Populate a single row and add it to table container +// * @param row +// * @param options +// * @param tableContainer +// */ +// private populateRow(row: number, options: (Object | string[])[], tableContainer: HTMLElement): HTMLElement { +// const columns = this.columns_; + +// const rowContent = document.createElement('div'); +// rowContent.className = 'blocklyGridPickerRow'; + +// for (let i = (columns * row); i < Math.min((columns * row) + columns, options.length); i++) { +// let content = (options[i] as any)[0]; // Human-readable text or image. +// const value = (options[i] as any)[1]; // Language-neutral value. + +// const menuItem = document.createElement('div'); +// menuItem.className = 'goog-menuitem goog-option'; +// menuItem.setAttribute('id', ':' + i); // For aria-activedescendant +// menuItem.setAttribute('role', 'menuitem'); +// menuItem.style.userSelect = 'none'; +// menuItem.title = content['alt'] || content; +// menuItem.setAttribute('data-value', value); + +// const menuItemContent = document.createElement('div'); +// menuItemContent.setAttribute('class', 'goog-menuitem-content'); +// menuItemContent.title = content['alt'] || content; +// menuItemContent.setAttribute('data-value', value); + +// const hasImages = typeof content == 'object'; + +// // Set colour +// let backgroundColour = this.backgroundColour_; +// if (value == this.getValue()) { +// // This option is selected +// menuItem.setAttribute('aria-selected', 'true'); +// pxt.BrowserUtils.addClass(menuItem, 'goog-option-selected'); +// backgroundColour = (this.sourceBlock_ as Blockly.BlockSvg).getColourTertiary(); + +// // Save so we can scroll to it later +// this.selectedItemDom = menuItem; + +// if (hasImages && !this.shouldShowTooltips()) { +// this.updateSelectedBar_(content, value); +// } +// } + +// menuItem.style.backgroundColor = backgroundColour; +// menuItem.style.borderColor = this.borderColour_; + + +// if (hasImages) { +// // An image, not text. +// const buttonImg = new Image(content['width'], content['height']); +// buttonImg.setAttribute('draggable', 'false'); +// if (!('IntersectionObserver' in window)) { +// // No intersection observer support, set the image url immediately +// buttonImg.src = content['src']; +// } else { +// buttonImg.src = FieldGridPicker.DEFAULT_IMG; +// buttonImg.setAttribute('data-src', content['src']); +// this.observer.observe(buttonImg); +// } +// buttonImg.alt = content['alt'] || ''; +// buttonImg.setAttribute('data-value', value); +// menuItemContent.appendChild(buttonImg); +// } else { +// // text +// menuItemContent.textContent = content; +// } + +// if (this.shouldShowTooltips()) { +// Blockly.bindEvent_(menuItem, 'click', this, this.buttonClickAndClose_); + +// // Setup hover tooltips +// const xOffset = (this.sourceBlock_.RTL ? -this.tooltipConfig_.xOffset : this.tooltipConfig_.xOffset); +// const yOffset = this.tooltipConfig_.yOffset; + +// Blockly.bindEvent_(menuItem, 'mousemove', this, (e: MouseEvent) => { +// if (hasImages) { +// this.gridTooltip_.style.top = `${e.clientY + yOffset}px`; +// this.gridTooltip_.style.left = `${e.clientX + xOffset}px`; +// // Set tooltip text +// const touchTarget = document.elementFromPoint(e.clientX, e.clientY); +// const title = (touchTarget as any).title || (touchTarget as any).alt; +// this.gridTooltip_.textContent = title; +// // Show the tooltip +// this.gridTooltip_.style.visibility = title ? 'visible' : 'hidden'; +// this.gridTooltip_.style.display = title ? '' : 'none'; +// } + +// pxt.BrowserUtils.addClass(menuItem, 'goog-menuitem-highlight'); +// tableContainer.setAttribute('aria-activedescendant', menuItem.id); +// }); + +// Blockly.bindEvent_(menuItem, 'mouseout', this, (e: MouseEvent) => { +// if (hasImages) { +// // Hide the tooltip +// this.gridTooltip_.style.visibility = 'hidden'; +// this.gridTooltip_.style.display = 'none'; +// } + +// pxt.BrowserUtils.removeClass(menuItem, 'goog-menuitem-highlight'); +// tableContainer.removeAttribute('aria-activedescendant'); +// }); +// } else { +// if (hasImages) { +// // Show the selected bar +// this.selectedBar_.style.display = ''; + +// // Show the selected item (in the selected bar) +// Blockly.bindEvent_(menuItem, 'click', this, (e: MouseEvent) => { +// if (this.closeModal_) { +// this.buttonClick_(e); +// } else { +// // Clear all current hovers. +// const currentHovers = tableContainer.getElementsByClassName('goog-menuitem-highlight'); +// for (let i = 0; i < currentHovers.length; i++) { +// pxt.BrowserUtils.removeClass(currentHovers[i] as HTMLElement, 'goog-menuitem-highlight'); +// } +// // Set hover on current item +// pxt.BrowserUtils.addClass(menuItem, 'goog-menuitem-highlight'); + +// this.updateSelectedBar_(content, value); +// } +// }); +// } else { +// Blockly.bindEvent_(menuItem, 'click', this, this.buttonClickAndClose_); +// Blockly.bindEvent_(menuItem, 'mouseup', this, this.buttonClickAndClose_); +// } +// } + +// menuItem.appendChild(menuItemContent); +// rowContent.appendChild(menuItem); + +// if (i == 0) { +// this.firstItem_ = menuItem; +// } +// } + +// return rowContent; +// } + +// /** +// * Callback for when a button is clicked inside the drop-down. +// * Should be bound to the FieldIconMenu. +// * @param {Event} e DOM event for the click/touch +// * @private +// */ +// protected buttonClick_ = function (e: any) { +// let value = e.target.getAttribute('data-value'); +// if (value !== null) { +// this.setValue(value); + +// // Close the picker +// if (this.closeModal_) { +// this.close(); +// this.closeModal_ = false; +// } +// } +// }; + +// protected buttonClickAndClose_ = function (e: any) { +// this.closeModal_ = true; +// this.buttonClick_(e); +// }; + +// /** +// * Whether or not to show a box around the dropdown menu. +// * @return {boolean} True if we should show a box (rect) around the dropdown menu. Otherwise false. +// * @private +// */ +// shouldShowRect_() { +// return !this.hideRect_ ? !this.sourceBlock_.isShadow() : false; +// } + +// doClassValidation_(newValue: string) { +// return newValue; +// } + +// /** +// * Closes the gridpicker. +// */ +// private close() { +// this.disposeTooltip(); + +// Blockly.WidgetDiv.hideIfOwner(this); +// Blockly.Events.setGroup(false); +// } + +// /** +// * Getter method +// */ +// private getFirstItem() { +// return this.firstItem_; +// } + +// /** +// * Highlight first item in menu, de-select and de-highlight all others +// */ +// private highlightFirstItem(tableContainerDom: HTMLElement) { +// let menuItemsDom = tableContainerDom.childNodes; +// if (menuItemsDom.length && menuItemsDom[0].childNodes) { +// for (let row = 0; row < menuItemsDom.length; ++row) { +// let rowLength = menuItemsDom[row].childNodes.length +// for (let col = 0; col < rowLength; ++col) { +// const menuItem = menuItemsDom[row].childNodes[col] as HTMLElement +// pxt.BrowserUtils.removeClass(menuItem, "goog-menuitem-highlight"); +// pxt.BrowserUtils.removeClass(menuItem, "goog-option-selected"); +// } +// } +// let firstItem = menuItemsDom[0].childNodes[0] as HTMLElement; +// firstItem.className += " goog-menuitem-highlight" +// } +// } + +// /** +// * Scroll menu to item that equals current value of gridpicker +// */ +// private highlightAndScrollSelected(tableContainerDom: HTMLElement, scrollContainerDom: HTMLElement) { +// if (!this.selectedItemDom) return; +// goog.style.scrollIntoContainerView(this.selectedItemDom, scrollContainerDom, true); +// } + +// /** +// * Create a dropdown menu under the text. +// * @private +// */ +// public showEditor_() { +// Blockly.WidgetDiv.show(this, this.sourceBlock_.RTL, () => { +// this.onClose_(); +// }); + +// this.setupIntersectionObserver_(); + +// this.createTooltip_(); + +// const tableContainer = document.createElement("div"); +// this.positionMenu_(tableContainer); +// } + +// private positionMenu_(tableContainer: HTMLElement) { +// // Record viewport dimensions before adding the dropdown. +// const viewportBBox = Blockly.utils.getViewportBBox(); +// const anchorBBox = this.getAnchorDimensions_(); + +// const { paddingContainer, scrollContainer } = this.createWidget_(tableContainer); + +// const containerSize = { +// width: paddingContainer.offsetWidth, +// height: paddingContainer.offsetHeight +// }; //goog.style.getSize(paddingContainer); + +// // Set width +// const windowSize = goog.dom.getViewportSize(); +// if (this.width_ > windowSize.width) { +// this.width_ = windowSize.width; +// } +// tableContainer.style.width = this.width_ + 'px'; + +// let addedHeight = 0; +// if (this.hasSearchBar_) addedHeight += 50; // Account for search bar +// if (this.selectedBar_) addedHeight += 50; // Account for the selected bar + +// // Set height +// if (this.maxRows_) { +// // Calculate height +// const firstRowDom = tableContainer.children[0] as HTMLElement; +// const rowHeight = firstRowDom.offsetHeight; +// // Compute maxHeight using maxRows + 0.3 to partially show next row, to hint at scrolling +// let maxHeight = rowHeight * (this.maxRows_ + 0.3); +// if (windowSize.height < (maxHeight + addedHeight)) { +// maxHeight = windowSize.height - addedHeight; +// } +// if (containerSize.height > maxHeight) { +// scrollContainer.style.overflowY = "auto"; +// goog.style.setHeight(scrollContainer, maxHeight); +// containerSize.height = maxHeight; +// } +// } + +// containerSize.height += addedHeight; + +// // Position the menu. +// Blockly.WidgetDiv.positionWithAnchor(viewportBBox, anchorBBox, containerSize, +// this.sourceBlock_.RTL); + +// // (scrollContainer).focus(); + +// this.highlightAndScrollSelected(tableContainer, scrollContainer) +// }; + +// private shouldShowTooltips() { +// return !pxt.BrowserUtils.isMobile(); +// } + +// private getAnchorDimensions_() { +// const boundingBox = this.getScaledBBox() as any; +// if (this.sourceBlock_.RTL) { +// boundingBox.right += Blockly.FieldDropdown.CHECKMARK_OVERHANG; +// } else { +// boundingBox.left -= Blockly.FieldDropdown.CHECKMARK_OVERHANG; +// } +// return boundingBox; +// }; + +// private createWidget_(tableContainer: HTMLElement) { +// const div = Blockly.WidgetDiv.DIV; + +// const options = this.getOptions(); + +// // Container for the menu rows +// tableContainer.setAttribute("role", "menu"); +// tableContainer.setAttribute("aria-haspopup", "true"); + +// // Container used to limit the height of the tableContainer, because the tableContainer uses +// // display: table, which ignores height and maxHeight +// const scrollContainer = document.createElement("div"); + +// // Needed to correctly style borders and padding around the scrollContainer, because the padding around the +// // scrollContainer is part of the scrollable area and will not be correctly shown at the top and bottom +// // when scrolling +// const paddingContainer = document.createElement("div"); +// paddingContainer.style.border = `solid 1px ${this.borderColour_}`; + +// tableContainer.style.backgroundColor = this.backgroundColour_; +// scrollContainer.style.backgroundColor = this.backgroundColour_; +// paddingContainer.style.backgroundColor = this.backgroundColour_; + +// tableContainer.className = 'blocklyGridPickerMenu'; +// scrollContainer.className = 'blocklyGridPickerScroller'; +// paddingContainer.className = 'blocklyGridPickerPadder'; + +// paddingContainer.appendChild(scrollContainer); +// scrollContainer.appendChild(tableContainer); +// div.appendChild(paddingContainer); + +// // Search bar +// if (this.hasSearchBar_) { +// const searchBar = this.createSearchBar_(tableContainer, scrollContainer, options); +// paddingContainer.insertBefore(searchBar, paddingContainer.childNodes[0]); +// } + +// // Selected bar +// if (!this.shouldShowTooltips()) { +// this.selectedBar_ = this.createSelectedBar_(); +// paddingContainer.appendChild(this.selectedBar_); +// } + +// // Render elements +// this.populateTableContainer(options, tableContainer, scrollContainer); + + +// return { paddingContainer, scrollContainer }; +// } + +// private createSearchBar_(tableContainer: HTMLElement, scrollContainer: HTMLElement, options: (Object | string[])[]) { +// const searchBarDiv = document.createElement("div"); +// searchBarDiv.setAttribute("class", "ui fluid icon input"); +// const searchIcon = document.createElement("i"); +// searchIcon.setAttribute("class", "search icon"); +// const searchBar = document.createElement("input"); +// searchBar.setAttribute("type", "search"); +// searchBar.setAttribute("id", "search-bar"); +// searchBar.setAttribute("class", "blocklyGridPickerSearchBar"); +// searchBar.setAttribute("placeholder", pxt.Util.lf("Search")); +// searchBar.addEventListener("click", () => { +// searchBar.focus(); +// searchBar.setSelectionRange(0, searchBar.value.length); +// }); + +// // Search on key change +// searchBar.addEventListener("keyup", pxt.Util.debounce(() => { +// let text = searchBar.value; +// let re = new RegExp(text, "i"); +// let filteredOptions = options.filter((block) => { +// const alt = (block as any)[0].alt; // Human-readable text or image. +// const value = (block as any)[1]; // Language-neutral value. +// return alt ? re.test(alt) : re.test(value); +// }) +// this.populateTableContainer.bind(this)(filteredOptions, tableContainer, scrollContainer); +// if (text) { +// this.highlightFirstItem(tableContainer) +// } else { +// this.highlightAndScrollSelected(tableContainer, scrollContainer) +// } +// // Hide the tooltip +// this.gridTooltip_.style.visibility = 'hidden'; +// this.gridTooltip_.style.display = 'none'; +// }, 300, false)); + +// // Select the first item if the enter key is pressed +// searchBar.addEventListener("keyup", (e: KeyboardEvent) => { +// const code = e.which; +// if (code == 13) { /* Enter key */ +// // Select the first item in the list +// const firstRow = tableContainer.childNodes[0] as HTMLElement; +// if (firstRow) { +// const firstItem = firstRow.childNodes[0] as HTMLElement; +// if (firstItem) { +// this.closeModal_ = true; +// firstItem.click(); +// } +// } +// } +// }); + +// searchBarDiv.appendChild(searchBar); +// searchBarDiv.appendChild(searchIcon); + +// return searchBarDiv; +// } + +// private createSelectedBar_() { +// const selectedBar = document.createElement("div"); +// selectedBar.setAttribute("class", "blocklyGridPickerSelectedBar"); +// selectedBar.style.display = 'none'; + +// const selectedWrapper = document.createElement("div"); +// const selectedImgWrapper = document.createElement("div"); +// selectedImgWrapper.className = 'blocklyGridPickerSelectedImage'; +// selectedWrapper.appendChild(selectedImgWrapper); + +// this.selectedImg_ = document.createElement("img"); +// this.selectedImg_.setAttribute('width', '30px'); +// this.selectedImg_.setAttribute('height', '30px'); +// this.selectedImg_.setAttribute('draggable', 'false'); +// this.selectedImg_.style.display = 'none'; +// this.selectedImg_.src = FieldGridPicker.DEFAULT_IMG; +// selectedImgWrapper.appendChild(this.selectedImg_); + +// this.selectedBarText_ = document.createElement("span"); +// this.selectedBarText_.className = 'blocklyGridPickerTooltip'; +// selectedWrapper.appendChild(this.selectedBarText_); + +// const buttonsWrapper = document.createElement("div"); +// const buttonsDiv = document.createElement("div"); +// buttonsDiv.className = 'ui buttons mini'; +// buttonsWrapper.appendChild(buttonsDiv); + +// const selectButton = document.createElement("button"); +// selectButton.className = "ui button icon green"; +// const selectButtonIcon = document.createElement("i"); +// selectButtonIcon.className = 'icon check'; +// selectButton.appendChild(selectButtonIcon); + +// Blockly.bindEvent_(selectButton, 'click', this, () => { +// this.setValue(this.selectedBarValue_); +// this.close(); +// }); + +// const cancelButton = document.createElement("button"); +// cancelButton.className = "ui button icon red"; +// const cancelButtonIcon = document.createElement("i"); +// cancelButtonIcon.className = 'icon cancel'; +// cancelButton.appendChild(cancelButtonIcon); + +// Blockly.bindEvent_(cancelButton, 'click', this, () => { +// this.close(); +// }); + +// buttonsDiv.appendChild(selectButton); +// buttonsDiv.appendChild(cancelButton); + +// selectedBar.appendChild(selectedWrapper); +// selectedBar.appendChild(buttonsWrapper); +// return selectedBar; +// } + +// private updateSelectedBar_(content: any, value: string) { +// if (content['src']) { +// this.selectedImg_.src = content['src']; +// this.selectedImg_.style.display = ''; +// } +// this.selectedImg_.alt = content['alt'] || content; +// this.selectedBarText_.textContent = content['alt'] || content; +// this.selectedBarValue_ = value; +// } + +// private setupIntersectionObserver_() { +// if (!('IntersectionObserver' in window)) return; + +// this.disposeIntersectionObserver(); + +// // setup intersection observer for the image +// const preloadImage = (el: HTMLImageElement) => { +// const lazyImageUrl = el.getAttribute('data-src'); +// if (lazyImageUrl) { +// el.src = lazyImageUrl; +// el.removeAttribute('data-src'); +// } +// } +// const config = { +// // If the image gets within 50px in the Y axis, start the download. +// rootMargin: '20px 0px', +// threshold: 0.01 +// }; +// const onIntersection: IntersectionObserverCallback = (entries) => { +// entries.forEach(entry => { +// // Are we in viewport? +// if (entry.intersectionRatio > 0) { +// // Stop watching and load the image +// this.observer.unobserve(entry.target); +// preloadImage(entry.target as HTMLImageElement); +// } +// }) +// } +// this.observer = new IntersectionObserver(onIntersection, config); +// } + +// private disposeIntersectionObserver() { +// if (this.observer) { +// this.observer = null; +// } +// } + +// /** +// * Disposes the tooltip DOM. +// * @private +// */ +// private disposeTooltip() { +// if (this.gridTooltip_) { +// pxsim.U.remove(this.gridTooltip_); +// this.gridTooltip_ = null; +// } +// } + +// private onClose_() { +// this.disposeTooltip(); +// } +// } +// } diff --git a/newblocks/fields/field_imagedropdown.ts b/newblocks/fields/field_imagedropdown.ts new file mode 100644 index 000000000000..05bfe9aa0f4f --- /dev/null +++ b/newblocks/fields/field_imagedropdown.ts @@ -0,0 +1,181 @@ +// /// + +// import * as Blockly from "blockly"; + +// namespace pxtblockly { + +// export interface FieldImageDropdownOptions extends Blockly.FieldCustomDropdownOptions { +// columns?: string; +// maxRows?: string; +// width?: string; +// } + +// export class FieldImageDropdown extends Blockly.FieldDropdown implements Blockly.FieldCustom { +// public isFieldCustom_ = true; +// // Width in pixels +// protected width_: number; + +// // Columns in grid +// protected columns_: number; + +// // Number of rows to display (if there are extra rows, the picker will be scrollable) +// protected maxRows_: number; + +// protected backgroundColour_: string; +// protected borderColour_: string; + +// protected savedPrimary_: string; + +// constructor(text: string, options: FieldImageDropdownOptions, validator?: Function) { +// super(options.data); + +// this.columns_ = parseInt(options.columns); +// this.maxRows_ = parseInt(options.maxRows) || 0; +// this.width_ = parseInt(options.width) || 300; + +// this.backgroundColour_ = pxtblockly.parseColour(options.colour); +// this.borderColour_ = pxt.toolbox.fadeColor(this.backgroundColour_, 0.4, false); +// } + +// /** +// * Create a dropdown menu under the text. +// * @private +// */ +// public showEditor_() { +// // If there is an existing drop-down we own, this is a request to hide the drop-down. +// if (Blockly.DropDownDiv.hideIfOwner(this)) { +// return; +// } +// // If there is an existing drop-down someone else owns, hide it immediately and clear it. +// Blockly.DropDownDiv.hideWithoutAnimation(); +// Blockly.DropDownDiv.clearContent(); +// // Populate the drop-down with the icons for this field. +// let dropdownDiv = Blockly.DropDownDiv.getContentDiv() as HTMLElement; +// let contentDiv = document.createElement('div'); +// // Accessibility properties +// contentDiv.setAttribute('role', 'menu'); +// contentDiv.setAttribute('aria-haspopup', 'true'); +// const options = this.getOptions(); +// let maxButtonHeight: number = 0; +// for (let i = 0; i < options.length; i++) { +// let content = (options[i] as any)[0]; // Human-readable text or image. +// const value = (options[i] as any)[1]; // Language-neutral value. +// // Icons with the type property placeholder take up space but don't have any functionality +// // Use for special-case layouts +// if (content.type == 'placeholder') { +// let placeholder = document.createElement('span'); +// placeholder.setAttribute('class', 'blocklyDropDownPlaceholder'); +// placeholder.style.width = content.width + 'px'; +// placeholder.style.height = content.height + 'px'; +// contentDiv.appendChild(placeholder); +// continue; +// } +// let button = document.createElement('button'); +// button.setAttribute('id', ':' + i); // For aria-activedescendant +// button.setAttribute('role', 'menuitem'); +// button.setAttribute('class', 'blocklyDropDownButton'); +// button.title = content.alt; +// let buttonSize = content.height; +// if (this.columns_) { +// buttonSize = ((this.width_ / this.columns_) - 8); +// button.style.width = buttonSize + 'px'; +// button.style.height = buttonSize + 'px'; +// } else { +// button.style.width = content.width + 'px'; +// button.style.height = content.height + 'px'; +// } +// if (buttonSize > maxButtonHeight) { +// maxButtonHeight = buttonSize; +// } +// let backgroundColor = this.backgroundColour_; +// if (value == this.getValue()) { +// // This icon is selected, show it in a different colour +// backgroundColor = (this.sourceBlock_ as Blockly.BlockSvg).getColourTertiary(); +// button.setAttribute('aria-selected', 'true'); +// } +// button.style.backgroundColor = backgroundColor; +// button.style.borderColor = this.borderColour_; +// Blockly.bindEvent_(button, 'click', this, this.buttonClick_); +// Blockly.bindEvent_(button, 'mouseover', button, function () { +// this.setAttribute('class', 'blocklyDropDownButton blocklyDropDownButtonHover'); +// contentDiv.setAttribute('aria-activedescendant', this.id); +// }); +// Blockly.bindEvent_(button, 'mouseout', button, function () { +// this.setAttribute('class', 'blocklyDropDownButton'); +// contentDiv.removeAttribute('aria-activedescendant'); +// }); +// let buttonImg = document.createElement('img'); +// buttonImg.src = content.src; +// //buttonImg.alt = icon.alt; +// // Upon click/touch, we will be able to get the clicked element as e.target +// // Store a data attribute on all possible click targets so we can match it to the icon. +// button.setAttribute('data-value', value); +// buttonImg.setAttribute('data-value', value); +// button.appendChild(buttonImg); +// contentDiv.appendChild(button); +// } +// contentDiv.style.width = this.width_ + 'px'; +// dropdownDiv.appendChild(contentDiv); +// if (this.maxRows_) { +// // Limit the number of rows shown, but add a partial next row to indicate scrolling +// dropdownDiv.style.maxHeight = (this.maxRows_ + 0.4) * (maxButtonHeight + 8) + 'px'; +// } + +// if (pxt.BrowserUtils.isFirefox()) { +// // This is to compensate for the scrollbar that overlays content in Firefox. It +// // gets removed in onHide_() +// dropdownDiv.style.paddingRight = "20px"; +// } + +// Blockly.DropDownDiv.setColour(this.backgroundColour_, this.borderColour_); + +// Blockly.DropDownDiv.showPositionedByField(this, this.onHide_.bind(this)); + +// let source = this.sourceBlock_ as Blockly.BlockSvg; +// this.savedPrimary_ = source?.getColour(); +// if (source?.isShadow()) { +// source.setColour(source.getColourTertiary()); +// } else if (this.borderRect_) { +// this.borderRect_.setAttribute('fill', source.getColourTertiary()); +// } +// } + +// doValueUpdate_(newValue: any): void { +// (this as any).selectedOption_ = undefined; +// super.doValueUpdate_(newValue); +// } + +// /** +// * Callback for when a button is clicked inside the drop-down. +// * Should be bound to the FieldIconMenu. +// * @param {Event} e DOM event for the click/touch +// * @private +// */ +// protected buttonClick_ = function (e: any) { +// let value = e.target.getAttribute('data-value'); +// if (!value) return; +// this.setValue(value); +// Blockly.DropDownDiv.hide(); +// }; + +// /** +// * Callback for when the drop-down is hidden. +// */ +// protected onHide_() { +// let content = Blockly.DropDownDiv.getContentDiv() as HTMLElement; +// content.removeAttribute('role'); +// content.removeAttribute('aria-haspopup'); +// content.removeAttribute('aria-activedescendant'); +// content.style.width = ''; +// content.style.paddingRight = ''; +// content.style.maxHeight = ''; + +// let source = this.sourceBlock_ as Blockly.BlockSvg; +// if (source?.isShadow()) { +// this.sourceBlock_.setColour(this.savedPrimary_); +// } else if (this.borderRect_) { +// this.borderRect_.setAttribute('fill', this.savedPrimary_); +// } +// }; +// } +// } \ No newline at end of file diff --git a/newblocks/fields/field_images.ts b/newblocks/fields/field_images.ts new file mode 100644 index 000000000000..8b8d3e8ce58d --- /dev/null +++ b/newblocks/fields/field_images.ts @@ -0,0 +1,137 @@ +// /// + +// import * as Blockly from "blockly"; + +// namespace pxtblockly { +// export interface FieldImagesOptions extends pxtblockly.FieldImageDropdownOptions { +// sort?: boolean; +// addLabel?: string; +// } + +// export class FieldImages extends pxtblockly.FieldImageDropdown implements Blockly.FieldCustom { +// public isFieldCustom_ = true; + +// private shouldSort_: boolean; + +// protected addLabel_: boolean; + +// constructor(text: string, options: FieldImagesOptions, validator?: Function) { +// super(text, options, validator); + +// this.shouldSort_ = options.sort; +// this.addLabel_ = !!options.addLabel; +// } + +// /** +// * Create a dropdown menu under the text. +// * @private +// */ +// public showEditor_() { +// // If there is an existing drop-down we own, this is a request to hide the drop-down. +// if (Blockly.DropDownDiv.hideIfOwner(this)) { +// return; +// } +// let sourceBlock = this.sourceBlock_ as Blockly.BlockSvg; +// // If there is an existing drop-down someone else owns, hide it immediately and clear it. +// Blockly.DropDownDiv.hideWithoutAnimation(); +// Blockly.DropDownDiv.clearContent(); +// // Populate the drop-down with the icons for this field. +// let dropdownDiv = Blockly.DropDownDiv.getContentDiv(); +// let contentDiv = document.createElement('div'); +// // Accessibility properties +// contentDiv.setAttribute('role', 'menu'); +// contentDiv.setAttribute('aria-haspopup', 'true'); +// const options = this.getOptions(); +// if (this.shouldSort_) options.sort(); +// for (let i = 0; i < options.length; i++) { +// const content = (options[i] as any)[0]; // Human-readable text or image. +// const value = (options[i] as any)[1]; // Language-neutral value. +// // Icons with the type property placeholder take up space but don't have any functionality +// // Use for special-case layouts +// if (content.type == 'placeholder') { +// let placeholder = document.createElement('span'); +// placeholder.setAttribute('class', 'blocklyDropDownPlaceholder'); +// placeholder.style.width = content.width + 'px'; +// placeholder.style.height = content.height + 'px'; +// contentDiv.appendChild(placeholder); +// continue; +// } +// let button = document.createElement('button'); +// button.setAttribute('id', ':' + i); // For aria-activedescendant +// button.setAttribute('role', 'menuitem'); +// button.setAttribute('class', 'blocklyDropDownButton'); +// button.title = content.alt; +// if ((this as any).columns_) { +// button.style.width = (((this as any).width_ / (this as any).columns_) - 8) + 'px'; +// //button.style.height = ((this.width_ / this.columns_) - 8) + 'px'; +// } else { +// button.style.width = content.width + 'px'; +// button.style.height = content.height + 'px'; +// } +// let backgroundColor = sourceBlock.getColour(); +// if (value == this.getValue()) { +// // This icon is selected, show it in a different colour +// backgroundColor = sourceBlock.getColourTertiary(); +// button.setAttribute('aria-selected', 'true'); +// } +// button.style.backgroundColor = backgroundColor; +// button.style.borderColor = sourceBlock.getColourTertiary(); +// Blockly.bindEvent_(button, 'click', this, this.buttonClick_); +// Blockly.bindEvent_(button, 'mouseover', button, function () { +// this.setAttribute('class', 'blocklyDropDownButton blocklyDropDownButtonHover'); +// contentDiv.setAttribute('aria-activedescendant', this.id); +// }); +// Blockly.bindEvent_(button, 'mouseout', button, function () { +// this.setAttribute('class', 'blocklyDropDownButton'); +// contentDiv.removeAttribute('aria-activedescendant'); +// }); +// let buttonImg = document.createElement('img'); +// buttonImg.src = content.src; +// //buttonImg.alt = icon.alt; +// // Upon click/touch, we will be able to get the clicked element as e.target +// // Store a data attribute on all possible click targets so we can match it to the icon. +// button.setAttribute('data-value', value); +// buttonImg.setAttribute('data-value', value); +// button.appendChild(buttonImg); +// if (this.addLabel_) { +// const buttonText = this.createTextNode_(content.alt); +// buttonText.setAttribute('data-value', value); +// button.appendChild(buttonText); +// } +// contentDiv.appendChild(button); +// } +// contentDiv.style.width = (this as any).width_ + 'px'; +// dropdownDiv.appendChild(contentDiv); + +// Blockly.DropDownDiv.setColour(sourceBlock.getColour(), sourceBlock.getColourTertiary()); + +// // Position based on the field position. +// Blockly.DropDownDiv.showPositionedByField(this, this.onHideCallback.bind(this)); + +// // Update colour to look selected. +// this.savedPrimary_ = sourceBlock?.getColour(); +// if (sourceBlock?.isShadow()) { +// sourceBlock.setColour(sourceBlock.style.colourTertiary); +// } else if (this.borderRect_) { +// this.borderRect_.setAttribute('fill', sourceBlock.style.colourTertiary); +// } +// } + +// // Update color (deselect) on dropdown hide +// protected onHideCallback() { +// let source = this.sourceBlock_ as Blockly.BlockSvg; +// if (source?.isShadow()) { +// source.setColour(this.savedPrimary_); +// } else if (this.borderRect_) { +// this.borderRect_.setAttribute('fill', this.savedPrimary_); +// } +// } + +// protected createTextNode_(text: string) { +// const textSpan = document.createElement('span'); +// textSpan.setAttribute('class', 'blocklyDropdownTextLabel'); +// textSpan.textContent = text; +// return textSpan; +// } +// } +// } \ No newline at end of file diff --git a/newblocks/fields/field_kind.ts b/newblocks/fields/field_kind.ts new file mode 100644 index 000000000000..dd8466e95d82 --- /dev/null +++ b/newblocks/fields/field_kind.ts @@ -0,0 +1,225 @@ +/// + +import * as Blockly from "blockly"; +import { getAllFields } from "./field_utils"; + +export class FieldKind extends Blockly.FieldDropdown { + constructor(private opts: pxtc.KindInfo) { + super(createMenuGenerator(opts)); + } + + initView() { + super.initView(); + } + + onItemSelected_(menu: Blockly.Menu, menuItem: Blockly.MenuItem) { + const value = menuItem.getValue(); + if (value === "CREATE") { + promptAndCreateKind(this.sourceBlock_.workspace, this.opts, lf("New {0}:", this.opts.memberName), + newName => newName && this.setValue(newName)); + } + else if (value === "RENAME") { + const ws = this.sourceBlock_.workspace; + const toRename = ws.getVariable(this.value_, kindType(this.opts.name)) + const oldName = toRename.name; + + if (this.opts.initialMembers.indexOf(oldName) !== -1) { + Blockly.dialog.alert(lf("The built-in {0} '{1}' cannot be renamed. Try creating a new kind instead!", this.opts.memberName, oldName)); + return; + } + + promptAndRenameKind( + ws, + { ...this.opts, toRename }, + lf("Rename '{0}':", oldName), + newName => { + // Update the values of all existing field instances + const allFields = getAllFields(ws, field => field instanceof FieldKind + && field.getValue() === oldName + && field.opts.name === this.opts.name); + for (const field of allFields) { + field.ref.setValue(newName); + } + } + ); + } + else if (value === "DELETE") { + const ws = this.sourceBlock_.workspace; + const toDelete = ws.getVariable(this.value_, kindType(this.opts.name)); + const varName = toDelete.name; + + if (this.opts.initialMembers.indexOf(varName) !== -1) { + Blockly.dialog.alert(lf("The built-in {0} '{1}' cannot be deleted.", this.opts.memberName, varName)); + return; + } + + const uses = getAllFields(ws, field => field instanceof FieldKind + && field.getValue() === varName + && field.opts.name === this.opts.name); + + if (uses.length > 1) { + Blockly.dialog.confirm(lf("Delete {0} uses of the \"{1}\" {2}?", uses.length, varName, this.opts.memberName), response => { + if (!response) return; + Blockly.Events.setGroup(true); + for (const use of uses) { + use.block.dispose(true); + } + ws.deleteVariableById(toDelete.getId()); + this.setValue(this.opts.initialMembers[0]); + Blockly.Events.setGroup(false); + }); + } + else { + ws.deleteVariableById(toDelete.getId()); + this.setValue(this.opts.initialMembers[0]); + } + } + else { + super.onItemSelected_(menu, menuItem); + } + } + + doClassValidation_(value: any) { + // update cached option list when adding a new kind + if (this.opts?.initialMembers && !this.opts.initialMembers.find(el => el == value)) this.getOptions(); + return super.doClassValidation_(value); + } + + getOptions(opt_useCache?: boolean) { + this.initVariables(); + return super.getOptions(opt_useCache); + } + + private initVariables() { + if (this.sourceBlock_ && this.sourceBlock_.workspace) { + const ws = this.sourceBlock_.workspace; + const existing = getExistingKindMembers(ws, this.opts.name); + this.opts.initialMembers.forEach(memberName => { + if (existing.indexOf(memberName) === -1) { + createVariableForKind(ws, this.opts, memberName); + } + }); + + if (this.getValue() === "CREATE" || this.getValue() === "RENAME" || this.getValue() === "DELETE") { + if (this.opts.initialMembers.length) { + this.setValue(this.opts.initialMembers[0]); + } + } + } + } +} + +function createMenuGenerator(opts: pxtc.KindInfo): Blockly.MenuGeneratorFunction { + return function() { + const that = this as FieldKind; + const res: [string, string][] = []; + + const sourceBlock = that.getSourceBlock(); + + if (sourceBlock?.workspace && !sourceBlock.isInFlyout) { + const options = sourceBlock.workspace.getVariablesOfType(kindType(opts.name)); + options.forEach(model => { + res.push([model.name, model.name]); + }); + } else { + // Can't create variables from within the flyout, so we just have to fake it + opts.initialMembers.forEach((e) => res.push([e, e]) ); + } + + + res.push([lf("Add a new {0}...", opts.memberName), "CREATE"]); + res.push([undefined, "SEPARATOR"]); + res.push([lf("Rename {0}...", opts.memberName), "RENAME"]); + res.push([lf("Delete {0}...", opts.memberName), "DELETE"]); + + return res; + } +} + +type PromptFunction = (ws: Blockly.Workspace, opts: U, message: string, cb: (newValue: string) => void) => void; + +function promptForName(ws: Blockly.Workspace, opts: U, message: string, cb: (newValue: string) => void, prompt: PromptFunction) { + Blockly.dialog.prompt(message, null, response => { + if (response) { + let nameIsValid = false; + if (pxtc.isIdentifierStart(response.charCodeAt(0), 2)) { + nameIsValid = true; + for (let i = 1; i < response.length; i++) { + if (!pxtc.isIdentifierPart(response.charCodeAt(i), 2)) { + nameIsValid = false; + } + } + } + + if (!nameIsValid) { + Blockly.dialog.alert(lf("Names must start with a letter and can only contain letters, numbers, '$', and '_'."), + () => promptForName(ws, opts, message, cb, prompt)); + return; + } + + if (pxt.blocks.isReservedWord(response) || response === "CREATE" || response === "RENAME" || response === "DELETE") { + Blockly.dialog.alert(lf("'{0}' is a reserved word and cannot be used.", response), + () => promptForName(ws, opts, message, cb, prompt)); + return; + } + + const existing = getExistingKindMembers(ws, opts.name); + for (let i = 0; i < existing.length; i++) { + const name = existing[i]; + if (name === response) { + Blockly.dialog.alert(lf("A {0} named '{1}' already exists.", opts.memberName, response), + () => promptForName(ws, opts, message, cb, prompt)); + return; + } + } + + if (response === opts.createFunctionName) { + Blockly.dialog.alert(lf("'{0}' is a reserved name.", opts.createFunctionName), + () => promptForName(ws, opts, message, cb, prompt)); + } + + cb(response); + } + }/* FIXME (riknoll), { placeholder: opts.promptHint }*/); +} + +function promptAndCreateKind(ws: Blockly.Workspace, opts: pxtc.KindInfo, message: string, cb: (newValue: string) => void) { + const responseHandler = (response: string) => { + cb(createVariableForKind(ws, opts, response)); + }; + + promptForName(ws, opts, message, responseHandler, promptAndCreateKind); +} + +interface RenameOptions extends pxtc.KindInfo { + toRename: Blockly.VariableModel; +} + +function promptAndRenameKind(ws: Blockly.Workspace, opts: RenameOptions, message: string, cb: (newValue: string) => void) { + const responseHandler = (response: string) => { + ws.getVariableMap().renameVariable(opts.toRename, response); + cb(response); + }; + + promptForName(ws, opts, message, responseHandler, promptAndRenameKind); +} + + +function getExistingKindMembers(ws: Blockly.Workspace, kindName: string): string[] { + const existing = ws.getVariablesOfType(kindType(kindName)); + if (existing && existing.length) { + return existing.map(m => m.name); + } + else { + return []; + } +} + +function createVariableForKind(ws: Blockly.Workspace, opts: pxtc.KindInfo, newName: string): string { + Blockly.Variables.getOrCreateVariablePackage(ws, null, newName, kindType(opts.name)); + return newName; +} + +function kindType(name: string) { + return "KIND_" + name; +} \ No newline at end of file diff --git a/newblocks/fields/field_ledmatrix.ts b/newblocks/fields/field_ledmatrix.ts new file mode 100644 index 000000000000..64b8b8e895a7 --- /dev/null +++ b/newblocks/fields/field_ledmatrix.ts @@ -0,0 +1,350 @@ +/// +/// + +import * as Blockly from "blockly"; +import { FieldCustom } from "./field_utils"; + +const rowRegex = /^.*[\.#].*$/; + +enum LabelMode { + None, + Number, + Letter +} + +export class FieldMatrix extends Blockly.Field implements FieldCustom { + private static CELL_WIDTH = 25; + private static CELL_HORIZONTAL_MARGIN = 7; + private static CELL_VERTICAL_MARGIN = 5; + private static CELL_CORNER_RADIUS = 5; + private static BOTTOM_MARGIN = 9; + private static Y_AXIS_WIDTH = 9; + private static X_AXIS_HEIGHT = 10; + private static TAB = " "; + + public isFieldCustom_ = true; + public SERIALIZABLE = true; + + private params: any; + private onColor = "#FFFFFF"; + private offColor: string; + private static DEFAULT_OFF_COLOR = "#000000"; + + private scale = 1; + // The number of columns + private matrixWidth: number = 5; + + // The number of rows + private matrixHeight: number = 5; + + private yAxisLabel: LabelMode = LabelMode.None; + private xAxisLabel: LabelMode = LabelMode.None; + + private cellState: boolean[][] = []; + private cells: SVGRectElement[][] = []; + private elt: SVGSVGElement; + + private currentDragState_: boolean; + + constructor(text: string, params: any, validator?: Blockly.FieldValidator) { + super(text, validator); + this.params = params; + + if (this.params.rows !== undefined) { + let val = parseInt(this.params.rows); + if (!isNaN(val)) { + this.matrixHeight = val; + } + } + + if (this.params.columns !== undefined) { + let val = parseInt(this.params.columns); + if (!isNaN(val)) { + this.matrixWidth = val; + } + } + + if (this.params.onColor !== undefined) { + this.onColor = this.params.onColor; + } + + if (this.params.offColor !== undefined) { + this.offColor = this.params.offColor; + } + + if (this.params.scale !== undefined) + this.scale = Math.max(0.6, Math.min(2, Number(this.params.scale))); + else if (Math.max(this.matrixWidth, this.matrixHeight) > 15) + this.scale = 0.85; + else if (Math.max(this.matrixWidth, this.matrixHeight) > 10) + this.scale = 0.9; + } + + /** + * Show the inline free-text editor on top of the text. + * @private + */ + showEditor_() { + // Intentionally left empty + } + + private initMatrix() { + if (!this.sourceBlock_.isInsertionMarker()) { + this.elt = pxsim.svg.parseString(``); + + // Initialize the matrix that holds the state + for (let i = 0; i < this.matrixWidth; i++) { + this.cellState.push([]) + this.cells.push([]); + for (let j = 0; j < this.matrixHeight; j++) { + this.cellState[i].push(false); + } + } + + this.restoreStateFromString(); + + // Create the cells of the matrix that is displayed + for (let i = 0; i < this.matrixWidth; i++) { + for (let j = 0; j < this.matrixHeight; j++) { + this.createCell(i, j); + } + } + + this.updateValue(); + + if (this.xAxisLabel !== LabelMode.None) { + const y = this.scale * this.matrixHeight * (FieldMatrix.CELL_WIDTH + FieldMatrix.CELL_VERTICAL_MARGIN) + FieldMatrix.CELL_VERTICAL_MARGIN * 2 + FieldMatrix.BOTTOM_MARGIN + const xAxis = pxsim.svg.child(this.elt, "g", { transform: `translate(${0} ${y})` }); + for (let i = 0; i < this.matrixWidth; i++) { + const x = this.getYAxisWidth() + this.scale * i * (FieldMatrix.CELL_WIDTH + FieldMatrix.CELL_HORIZONTAL_MARGIN) + FieldMatrix.CELL_WIDTH / 2 + FieldMatrix.CELL_HORIZONTAL_MARGIN / 2; + const lbl = pxsim.svg.child(xAxis, "text", { x, class: "blocklyText" }) + lbl.textContent = this.getLabel(i, this.xAxisLabel); + } + } + + if (this.yAxisLabel !== LabelMode.None) { + const yAxis = pxsim.svg.child(this.elt, "g", {}); + for (let i = 0; i < this.matrixHeight; i++) { + const y = this.scale * i * (FieldMatrix.CELL_WIDTH + FieldMatrix.CELL_VERTICAL_MARGIN) + FieldMatrix.CELL_WIDTH / 2 + FieldMatrix.CELL_VERTICAL_MARGIN * 2; + const lbl = pxsim.svg.child(yAxis, "text", { x: 0, y, class: "blocklyText" }) + lbl.textContent = this.getLabel(i, this.yAxisLabel); + } + } + + this.fieldGroup_.replaceChild(this.elt, this.fieldGroup_.firstChild); + } + } + + private getLabel(index: number, mode: LabelMode) { + switch (mode) { + case LabelMode.Letter: + return String.fromCharCode(index + /*char code for A*/ 65); + default: + return (index + 1).toString(); + } + } + + private dontHandleMouseEvent_ = (ev: MouseEvent) => { + ev.stopPropagation(); + ev.preventDefault(); + } + + private clearLedDragHandler = (ev: MouseEvent) => { + const svgRoot = (this.sourceBlock_ as Blockly.BlockSvg).getSvgRoot(); + pxsim.pointerEvents.down.forEach(evid => svgRoot.removeEventListener(evid, this.dontHandleMouseEvent_)); + svgRoot.removeEventListener(pxsim.pointerEvents.move, this.dontHandleMouseEvent_); + document.removeEventListener(pxsim.pointerEvents.up, this.clearLedDragHandler); + document.removeEventListener(pxsim.pointerEvents.leave, this.clearLedDragHandler); + + (Blockly as any).Touch.clearTouchIdentifier(); + + this.elt.removeEventListener(pxsim.pointerEvents.move, this.handleRootMouseMoveListener); + + ev.stopPropagation(); + ev.preventDefault(); + } + + private createCell(x: number, y: number) { + const tx = this.scale * x * (FieldMatrix.CELL_WIDTH + FieldMatrix.CELL_HORIZONTAL_MARGIN) + FieldMatrix.CELL_HORIZONTAL_MARGIN + this.getYAxisWidth(); + const ty = this.scale * y * (FieldMatrix.CELL_WIDTH + FieldMatrix.CELL_VERTICAL_MARGIN) + FieldMatrix.CELL_VERTICAL_MARGIN; + + const cellG = pxsim.svg.child(this.elt, "g", { transform: `translate(${tx} ${ty})` }) as SVGGElement; + const cellRect = pxsim.svg.child(cellG, "rect", { + 'class': `blocklyLed${this.cellState[x][y] ? 'On' : 'Off'}`, + 'cursor': 'pointer', + width: this.scale * FieldMatrix.CELL_WIDTH, height: this.scale * FieldMatrix.CELL_WIDTH, + fill: this.getColor(x, y), + 'data-x': x, + 'data-y': y, + rx: Math.max(2, this.scale * FieldMatrix.CELL_CORNER_RADIUS) }) as SVGRectElement; + this.cells[x][y] = cellRect; + + if ((this.sourceBlock_.workspace as any).isFlyout) return; + + pxsim.pointerEvents.down.forEach(evid => cellRect.addEventListener(evid, (ev: MouseEvent) => { + const svgRoot = (this.sourceBlock_ as Blockly.BlockSvg).getSvgRoot(); + this.currentDragState_ = !this.cellState[x][y]; + + // select and hide chaff + Blockly.hideChaff(); + (this.sourceBlock_ as Blockly.BlockSvg).select(); + + this.toggleRect(x, y); + pxsim.pointerEvents.down.forEach(evid => svgRoot.addEventListener(evid, this.dontHandleMouseEvent_)); + svgRoot.addEventListener(pxsim.pointerEvents.move, this.dontHandleMouseEvent_); + + document.addEventListener(pxsim.pointerEvents.up, this.clearLedDragHandler); + document.addEventListener(pxsim.pointerEvents.leave, this.clearLedDragHandler); + + // Begin listening on the canvas and toggle any matches + this.elt.addEventListener(pxsim.pointerEvents.move, this.handleRootMouseMoveListener); + + ev.stopPropagation(); + ev.preventDefault(); + }, false)); + } + + private toggleRect = (x: number, y: number) => { + this.cellState[x][y] = this.currentDragState_; + this.updateValue(); + } + + private handleRootMouseMoveListener = (ev: MouseEvent) => { + let clientX; + let clientY; + if ((ev as any).changedTouches && (ev as any).changedTouches.length == 1) { + // Handle touch events + clientX = (ev as any).changedTouches[0].clientX; + clientY = (ev as any).changedTouches[0].clientY; + } else { + // All other events (pointer + mouse) + clientX = ev.clientX; + clientY = ev.clientY; + } + const target = document.elementFromPoint(clientX, clientY); + if (!target) return; + const x = target.getAttribute('data-x'); + const y = target.getAttribute('data-y'); + if (x != null && y != null) { + this.toggleRect(parseInt(x), parseInt(y)); + } + } + + private getColor(x: number, y: number) { + return this.cellState[x][y] ? this.onColor : (this.offColor || FieldMatrix.DEFAULT_OFF_COLOR); + } + + private getOpacity(x: number, y: number) { + const offOpacity = this.offColor ? '1.0': '0.2'; + return this.cellState[x][y] ? '1.0' : offOpacity; + } + + private updateCell(x: number, y: number) { + const cellRect = this.cells[x][y]; + cellRect.setAttribute("fill", this.getColor(x, y)); + cellRect.setAttribute("fill-opacity", this.getOpacity(x, y)); + cellRect.setAttribute('class', `blocklyLed${this.cellState[x][y] ? 'On' : 'Off'}`); + } + + setValue(newValue: string | number, restoreState = true) { + super.setValue(String(newValue)); + if (this.elt) { + if (restoreState) this.restoreStateFromString(); + + for (let x = 0; x < this.matrixWidth; x++) { + for (let y = 0; y < this.matrixHeight; y++) { + this.updateCell(x, y); + } + } + } + } + + render_() { + if (!this.visible_) { + this.markDirty(); + return; + } + + if (!this.elt) { + this.initMatrix(); + } + + + // The height and width must be set by the render function + this.size_.height = this.scale * Number(this.matrixHeight) * (FieldMatrix.CELL_WIDTH + FieldMatrix.CELL_VERTICAL_MARGIN) + FieldMatrix.CELL_VERTICAL_MARGIN * 2 + FieldMatrix.BOTTOM_MARGIN + this.getXAxisHeight() + this.size_.width = this.scale * Number(this.matrixWidth) * (FieldMatrix.CELL_WIDTH + FieldMatrix.CELL_HORIZONTAL_MARGIN) + this.getYAxisWidth(); + } + + // The return value of this function is inserted in the code + getValue() { + // getText() returns the value that is set by calls to setValue() + let text = removeQuotes(this.value_); + return `\`\n${FieldMatrix.TAB}${text}\n${FieldMatrix.TAB}\``; + } + + // Restores the block state from the text value of the field + private restoreStateFromString() { + let r = this.value_ as string; + if (r) { + const rows = r.split("\n").filter(r => rowRegex.test(r)); + + for (let y = 0; y < rows.length && y < this.matrixHeight; y++) { + let x = 0; + const row = rows[y]; + + for (let j = 0; j < row.length && x < this.matrixWidth; j++) { + if (isNegativeCharacter(row[j])) { + this.cellState[x][y] = false; + x++; + } + else if (isPositiveCharacter(row[j])) { + this.cellState[x][y] = true; + x++; + } + } + } + } + } + + // Composes the state into a string an updates the field's state + private updateValue() { + let res = ""; + for (let y = 0; y < this.matrixHeight; y++) { + for (let x = 0; x < this.matrixWidth; x++) { + res += (this.cellState[x][y] ? "#" : ".") + " " + } + res += "\n" + FieldMatrix.TAB + } + + // Blockly stores the state of the field as a string + this.setValue(res, false); + } + + private getYAxisWidth() { + return this.yAxisLabel === LabelMode.None ? 0 : FieldMatrix.Y_AXIS_WIDTH; + } + + private getXAxisHeight() { + return this.xAxisLabel === LabelMode.None ? 0 : FieldMatrix.X_AXIS_HEIGHT; + } +} + +function isPositiveCharacter(c: string) { + return c === "#" || c === "*" || c === "1"; +} + +function isNegativeCharacter(c: string) { + return c === "." || c === "_" || c === "0"; +} + + +const allQuotes = ["'", '"', "`"]; + +function removeQuotes(str: string) { + str = (str || "").trim(); + const start = str.charAt(0); + if (start === str.charAt(str.length - 1) && allQuotes.indexOf(start) !== -1) { + return str.substr(1, str.length - 2).trim(); + } + return str; +} \ No newline at end of file diff --git a/newblocks/fields/field_melodySandbox.ts b/newblocks/fields/field_melodySandbox.ts new file mode 100644 index 000000000000..27f896c33fd7 --- /dev/null +++ b/newblocks/fields/field_melodySandbox.ts @@ -0,0 +1,846 @@ +// /// + +// import * as Blockly from "blockly"; + +// namespace pxtblockly { +// import svg = pxt.svgUtil; +// export const HEADER_HEIGHT = 50; +// export const TOTAL_WIDTH = 300; + +// export class FieldCustomMelody extends Blockly.Field implements Blockly.FieldCustom { +// public isFieldCustom_ = true; +// public SERIALIZABLE = true; + +// protected params: U; +// private melody: pxtmelody.MelodyArray; +// private soundingKeys: number = 0; +// private numRow: number = 8; +// private numCol: number = 8; +// private tempo: number = 120; +// private stringRep: string; +// private isPlaying: boolean = false; +// private timeouts: any[] = []; // keep track of timeouts +// private invalidString: string; +// private prevString: string; + +// // DOM references +// private topDiv: HTMLDivElement; +// private editorDiv: HTMLDivElement; +// private gridDiv: SVGSVGElement; +// private bottomDiv: HTMLDivElement; +// private doneButton: HTMLButtonElement; +// private playButton: HTMLButtonElement; +// private playIcon: HTMLElement; +// private tempoInput: HTMLInputElement; + +// // grid elements +// private static CELL_WIDTH = 25; +// private static CELL_HORIZONTAL_MARGIN = 7; +// private static CELL_VERTICAL_MARGIN = 5; +// private static CELL_CORNER_RADIUS = 5; +// private elt: SVGSVGElement; +// private cells: SVGRectElement[][]; +// private static VIEWBOX_WIDTH: number; +// private static VIEWBOX_HEIGHT: number; + +// // preview field elements +// private static COLOR_BLOCK_WIDTH = 10; +// private static COLOR_BLOCK_HEIGHT = 20; +// private static COLOR_BLOCK_X = 20; +// private static COLOR_BLOCK_Y = 5; +// private static COLOR_BLOCK_SPACING = 2; +// private static MUSIC_ICON_WIDTH = 20; + +// // Use toggle from sprite editor +// private toggle: Toggle; +// private root: svg.SVG; +// private gallery: pxtmelody.MelodyGallery; + +// constructor(value: string, params: U, validator?: Function) { +// super(value, validator); +// this.params = params; +// this.createMelodyIfDoesntExist(); +// } + +// init() { +// super.init(); +// this.onInit(); +// } + +// showEditor_() { +// // If there is an existing drop-down someone else owns, hide it immediately and clear it. +// Blockly.DropDownDiv.hideWithoutAnimation(); +// Blockly.DropDownDiv.clearContent(); +// Blockly.DropDownDiv.setColour(this.getDropdownBackgroundColour(), this.getDropdownBorderColour()); + +// let contentDiv = Blockly.DropDownDiv.getContentDiv() as HTMLDivElement; +// pxt.BrowserUtils.addClass(contentDiv, "melody-content-div"); +// pxt.BrowserUtils.addClass(contentDiv.parentElement, "melody-editor-dropdown"); + +// this.gallery = new pxtmelody.MelodyGallery(); +// this.renderEditor(contentDiv); + +// this.prevString = this.getValue(); + +// // The webapp listens to this event and stops the simulator so that you don't get the melody +// // playing twice (once in the editor and once when the code runs in the sim) +// setMelodyEditorOpen(this.sourceBlock_, true); + +// Blockly.DropDownDiv.showPositionedByBlock(this, this.sourceBlock_ as Blockly.BlockSvg, () => { +// this.onEditorClose(); +// // revert all style attributes for dropdown div +// pxt.BrowserUtils.removeClass(contentDiv, "melody-content-div"); +// pxt.BrowserUtils.removeClass(contentDiv.parentElement, "melody-editor-dropdown"); + +// setMelodyEditorOpen(this.sourceBlock_, false); +// }); +// } + +// getValue() { +// this.stringRep = this.getTypeScriptValue(); +// return this.stringRep; +// } + +// doValueUpdate_(newValue: string) { +// if (newValue == null || newValue == "" || newValue == "\"\"" || (this.stringRep && this.stringRep === newValue)) { // ignore empty strings +// return; +// } +// this.stringRep = newValue; +// this.parseTypeScriptValue(newValue); +// super.doValueUpdate_(this.getValue()); +// } + +// getText_() { +// if (this.invalidString) return pxt.Util.lf("Invalid Input"); +// else return this.getValue(); +// } + +// // This will be run when the field is created (i.e. when it appears on the workspace) +// protected onInit() { +// this.render_(); +// this.createMelodyIfDoesntExist(); + +// if (!this.invalidString) { +// if (!this.fieldGroup_) { +// // Build the DOM. +// this.fieldGroup_ = Blockly.utils.dom.createSvgElement('g', {}, null) as SVGGElement; +// } +// if (!this.visible_) { +// (this.fieldGroup_ as any).style.display = 'none'; +// } + +// (this.sourceBlock_ as Blockly.BlockSvg).getSvgRoot().appendChild(this.fieldGroup_); +// this.updateFieldLabel(); +// } +// } + +// render_() { +// super.render_(); +// if (!this.invalidString) { +// this.size_.width = FieldCustomMelody.MUSIC_ICON_WIDTH + (FieldCustomMelody.COLOR_BLOCK_WIDTH + FieldCustomMelody.COLOR_BLOCK_SPACING) * this.numCol; +// } +// this.sourceBlock_.setColour("#ffffff"); +// } + +// // Render the editor that will appear in the dropdown div when the user clicks on the field +// protected renderEditor(div: HTMLDivElement) { +// let color = this.getDropdownBackgroundColour(); +// let secondaryColor = this.getDropdownBorderColour(); + +// this.topDiv = document.createElement("div"); +// pxt.BrowserUtils.addClass(this.topDiv, "melody-top-bar-div") + +// // Same toggle set up as sprite editor +// this.root = new svg.SVG(this.topDiv).id("melody-editor-header-controls"); +// this.toggle = new Toggle(this.root, { leftText: lf("Editor"), rightText: lf("Gallery"), baseColor: color }); +// this.toggle.onStateChange(isLeft => { +// if (isLeft) { +// this.hideGallery(); +// } +// else { +// this.showGallery(); +// } +// }); +// this.toggle.layout(); +// this.toggle.translate((TOTAL_WIDTH - this.toggle.width()) / 2, 0); + +// div.appendChild(this.topDiv); +// div.appendChild(this.gallery.getElement()); + +// this.editorDiv = document.createElement("div"); +// pxt.BrowserUtils.addClass(this.editorDiv, "melody-editor-div"); +// this.editorDiv.style.setProperty("background-color", secondaryColor); + +// this.gridDiv = this.createGridDisplay(); +// this.editorDiv.appendChild(this.gridDiv); + +// this.bottomDiv = document.createElement("div"); +// pxt.BrowserUtils.addClass(this.bottomDiv, "melody-bottom-bar-div"); + +// this.doneButton = document.createElement("button"); +// pxt.BrowserUtils.addClass(this.doneButton, "melody-confirm-button"); +// this.doneButton.innerText = lf("Done"); +// this.doneButton.addEventListener("click", () => this.onDone()); +// this.doneButton.style.setProperty("background-color", color); + +// this.playButton = document.createElement("button"); +// this.playButton.id = "melody-play-button"; +// this.playButton.addEventListener("click", () => this.togglePlay()); + +// this.playIcon = document.createElement("i"); +// this.playIcon.id = "melody-play-icon"; +// pxt.BrowserUtils.addClass(this.playIcon, "play icon"); +// this.playButton.appendChild(this.playIcon); + +// this.tempoInput = document.createElement("input"); +// pxt.BrowserUtils.addClass(this.tempoInput, "ui input"); +// this.tempoInput.type = "number"; +// this.tempoInput.title = lf("tempo"); +// this.tempoInput.id = "melody-tempo-input"; +// this.tempoInput.addEventListener("input", () => this.setTempo(+this.tempoInput.value)); +// this.syncTempoField(true); + +// this.bottomDiv.appendChild(this.tempoInput); +// this.bottomDiv.appendChild(this.playButton); +// this.bottomDiv.appendChild(this.doneButton); +// this.editorDiv.appendChild(this.bottomDiv); + +// div.appendChild(this.editorDiv); +// } + +// // Runs when the editor is closed by clicking on the Blockly workspace +// protected onEditorClose() { +// this.stopMelody(); +// if (this.gallery) { +// this.gallery.stopMelody(); +// } +// this.clearDomReferences(); + +// if (this.sourceBlock_ && Blockly.Events.isEnabled() && this.getValue() !== this.prevString) { +// Blockly.Events.fire(new Blockly.Events.BlockChange( +// this.sourceBlock_, 'field', this.name, this.prevString, this.getValue())); +// } + +// this.prevString = undefined; +// } + +// // when click done +// private onDone() { +// Blockly.DropDownDiv.hideIfOwner(this); +// this.onEditorClose(); +// } + +// private clearDomReferences() { +// this.topDiv = null; +// this.editorDiv = null; +// this.gridDiv = null; +// this.bottomDiv = null; +// this.doneButton = null; +// this.playButton = null; +// this.playIcon = null; +// this.tempoInput = null; +// this.elt = null; +// this.cells = null; +// this.toggle = null; +// this.root = null; +// this.gallery.clearDomReferences(); +// } + +// // This is the string that will be inserted into the user's TypeScript code +// protected getTypeScriptValue(): string { +// if (this.invalidString) { +// return this.invalidString; +// } +// if (this.melody) { +// return "\"" + this.melody.getStringRepresentation() + "\""; +// } +// return ""; +// } + +// // This should parse the string returned by getTypeScriptValue() and restore the state based on that +// protected parseTypeScriptValue(value: string) { +// let oldValue: string = value; +// try { +// value = value.slice(1, -1); // remove the boundary quotes +// value = value.trim(); // remove boundary white space +// this.createMelodyIfDoesntExist(); +// let notes: string[] = value.split(" "); + +// notes.forEach(n => { +// if (!this.isValidNote(n)) throw new Error(lf("Invalid note '{0}'. Notes can be C D E F G A B C5", n)); +// }); + +// this.melody.resetMelody(); + +// for (let j = 0; j < notes.length; j++) { +// if (notes[j] != "-") { +// let rowPos: number = pxtmelody.noteToRow(notes[j]); +// this.melody.updateMelody(rowPos, j); +// } +// } +// this.updateFieldLabel(); +// } catch (e) { +// pxt.log(e) +// this.invalidString = oldValue; +// } +// } + +// private isValidNote(note: string): boolean { +// switch (note) { +// case "C": +// case "D": +// case "E": +// case "F": +// case "G": +// case "A": +// case "B": +// case "C5": +// case "-": return true; +// } +// return false; +// } + +// // The width of the preview on the block itself +// protected getPreviewWidth(): number { +// this.updateSize_(); +// return this.size_.width; +// } + +// // The height of the preview on the block itself +// protected getPreviewHeight(): number { +// return this.getConstants()?.FIELD_BORDER_RECT_HEIGHT || 16; +// } + +// protected getDropdownBackgroundColour() { +// if (this.sourceBlock_.parentBlock_) { +// return this.sourceBlock_.parentBlock_.getColour(); +// } else { +// return "#3D3D3D"; +// } +// } + +// protected getDropdownBorderColour() { +// if (this.sourceBlock_.parentBlock_) { +// return (this.sourceBlock_.parentBlock_ as Blockly.BlockSvg).getColourTertiary(); +// } else { +// return "#2A2A2A"; +// } +// } + +// private updateFieldLabel(): void { +// if (!this.fieldGroup_) return; +// pxsim.U.clear(this.fieldGroup_); + +// let musicIcon = mkText("\uf001") +// .appendClass("melody-editor-field-icon") +// .at(6, 15); +// this.fieldGroup_.appendChild(musicIcon.el); + +// let notes = this.melody.getStringRepresentation().trim().split(" "); + +// for (let i = 0; i < notes.length; i++) { +// let className = pxtmelody.getColorClass(pxtmelody.noteToRow(notes[i])); +// const cb = new svg.Rect() +// .at((FieldCustomMelody.COLOR_BLOCK_WIDTH + FieldCustomMelody.COLOR_BLOCK_SPACING) * i + FieldCustomMelody.COLOR_BLOCK_X, FieldCustomMelody.COLOR_BLOCK_Y) +// .size(FieldCustomMelody.COLOR_BLOCK_WIDTH, FieldCustomMelody.COLOR_BLOCK_HEIGHT) +// .stroke("#898989", 1) +// .fill(getPlaceholderColor(pxtmelody.noteToRow(notes[i]))) +// .corners(3, 2); + +// pxt.BrowserUtils.addClass(cb.el, className); +// this.fieldGroup_.appendChild(cb.el); +// } +// } + +// private setTempo(tempo: number): void { +// // reset text input if input is invalid +// if ((isNaN(tempo) || tempo <= 0) && this.tempoInput) { +// this.tempoInput.value = this.tempo + ""; +// return +// } +// // update tempo and display to reflect new tempo +// if (this.tempo != tempo) { +// this.tempo = tempo; +// if (this.melody) { +// this.melody.setTempo(this.tempo); +// } +// if (this.tempoInput) { +// this.tempoInput.value = this.tempo + ""; +// } +// this.syncTempoField(false); +// } +// } + +// // sync value from tempo field on block with tempo in field editor +// private syncTempoField(blockToEditor: boolean): void { +// const s = this.sourceBlock_; +// if (s.parentBlock_) { +// const p = s.parentBlock_; +// for (const input of p.inputList) { +// if (input.name === "tempo" || input.name === "bpm") { +// const tempoBlock = input.connection.targetBlock(); +// if (tempoBlock) { +// if (blockToEditor) +// if (tempoBlock.getFieldValue("SLIDER")) { +// this.tempoInput.value = tempoBlock.getFieldValue("SLIDER"); +// this.tempo = +this.tempoInput.value; +// } else { +// this.tempoInput.value = this.tempo + ""; +// } +// else { // Editor to block +// if (tempoBlock.type === "math_number_minmax") { +// tempoBlock.setFieldValue(this.tempoInput.value, "SLIDER") +// } +// else { +// tempoBlock.setFieldValue(this.tempoInput.value, "NUM") +// } +// this.tempoInput.focus(); +// } +// } +// break; +// } +// } +// } +// } + +// // ms to hold note +// private getDuration(): number { +// return 60000 / this.tempo; +// } + +// private createMelodyIfDoesntExist(): boolean { +// if (!this.melody) { +// this.melody = new pxtmelody.MelodyArray(); +// return true; +// } +// return false; +// } + +// private onNoteSelect(row: number, col: number): void { +// // update melody array +// this.invalidString = null; +// this.melody.updateMelody(row, col); + +// if (this.melody.getValue(row, col) && !this.isPlaying) { +// this.playNote(row, col); +// } + +// this.updateGrid(); +// this.updateFieldLabel(); +// } + +// private updateGrid() { +// for (let row = 0; row < this.numRow; row++) { +// const rowClass = pxtmelody.getColorClass(row); + +// for (let col = 0; col < this.numCol; col++) { +// const cell = this.cells[row][col]; + +// if (this.melody.getValue(row, col)) { +// pxt.BrowserUtils.removeClass(cell, "melody-default"); +// pxt.BrowserUtils.addClass(cell, rowClass); +// } +// else { +// pxt.BrowserUtils.addClass(cell, "melody-default"); +// pxt.BrowserUtils.removeClass(cell, rowClass); +// } +// } +// } +// } + +// private playNote(rowNumber: number, colNumber?: number): void { +// let count: number = ++this.soundingKeys; + +// if (this.isPlaying) { +// this.timeouts.push(setTimeout(() => { +// this.playToneCore(rowNumber); +// }, colNumber * this.getDuration())); + +// this.timeouts.push(setTimeout(() => { +// pxt.AudioContextManager.stop(); +// }, (colNumber + 1) * this.getDuration())); +// } +// else { +// this.playToneCore(rowNumber); +// this.timeouts.push(setTimeout(() => { +// if (this.soundingKeys == count) +// pxt.AudioContextManager.stop(); +// }, this.getDuration())); +// } +// } + +// protected queueToneForColumn(column: number, delay: number, duration: number) { +// const start = setTimeout(() => { +// ++this.soundingKeys; +// pxt.AudioContextManager.stop(); + +// for (let i = 0; i < this.numRow; i++) { +// if (this.melody.getValue(i, column)) { +// this.playToneCore(i); +// } +// } +// this.highlightColumn(column, true); +// this.timeouts = this.timeouts.filter(t => t !== start); +// }, delay); + +// const end = setTimeout(() => { +// // pxt.AudioContextManager.stop(); +// this.timeouts = this.timeouts.filter(t => t !== end); +// this.highlightColumn(column, false); +// }, delay + duration) + +// this.timeouts.push(start); +// this.timeouts.push(end); +// } + +// protected playToneCore(row: number) { +// let tone: number = 0; + +// switch (row) { +// case 0: tone = 523; break; // Tenor C +// case 1: tone = 494; break; // Middle B +// case 2: tone = 440; break; // Middle A +// case 3: tone = 392; break; // Middle G +// case 4: tone = 349; break; // Middle F +// case 5: tone = 330; break; // Middle E +// case 6: tone = 294; break; // Middle D +// case 7: tone = 262; break; // Middle C +// } + +// pxt.AudioContextManager.tone(tone); +// } + +// private highlightColumn(col: number, on: boolean) { +// const cells = this.cells.map(row => row[col]); + +// cells.forEach(cell => { +// if (on) pxt.BrowserUtils.addClass(cell, "playing") +// else pxt.BrowserUtils.removeClass(cell, "playing") +// }); +// } + +// private createGridDisplay(): SVGSVGElement { +// FieldCustomMelody.VIEWBOX_WIDTH = (FieldCustomMelody.CELL_WIDTH + FieldCustomMelody.CELL_VERTICAL_MARGIN) * this.numCol + FieldCustomMelody.CELL_VERTICAL_MARGIN; +// if (pxt.BrowserUtils.isEdge()) FieldCustomMelody.VIEWBOX_WIDTH += 37; +// FieldCustomMelody.VIEWBOX_HEIGHT = (FieldCustomMelody.CELL_WIDTH + FieldCustomMelody.CELL_HORIZONTAL_MARGIN) * this.numRow + FieldCustomMelody.CELL_HORIZONTAL_MARGIN; +// this.elt = pxsim.svg.parseString(``); + +// // Create the cells of the matrix that is displayed +// this.cells = []; // initialize array that holds rect svg elements +// for (let i = 0; i < this.numRow; i++) { +// this.cells.push([]); +// } +// for (let i = 0; i < this.numRow; i++) { +// for (let j = 0; j < this.numCol; j++) { +// this.createCell(i, j); +// } +// } +// return this.elt; +// } + +// private createCell(x: number, y: number) { +// const tx = x * (FieldCustomMelody.CELL_WIDTH + FieldCustomMelody.CELL_HORIZONTAL_MARGIN) + FieldCustomMelody.CELL_HORIZONTAL_MARGIN; +// const ty = y * (FieldCustomMelody.CELL_WIDTH + FieldCustomMelody.CELL_VERTICAL_MARGIN) + FieldCustomMelody.CELL_VERTICAL_MARGIN; + +// const cellG = pxsim.svg.child(this.elt, "g", { transform: `translate(${ty} ${tx})` }) as SVGGElement; +// const cellRect = pxsim.svg.child(cellG, "rect", { +// 'cursor': 'pointer', +// 'width': FieldCustomMelody.CELL_WIDTH, +// 'height': FieldCustomMelody.CELL_WIDTH, +// 'stroke': 'white', +// 'data-x': x, +// 'data-y': y, +// 'rx': FieldCustomMelody.CELL_CORNER_RADIUS +// }) as SVGRectElement; + +// // add appropriate class so the cell has the correct fill color +// if (this.melody.getValue(x, y)) pxt.BrowserUtils.addClass(cellRect, pxtmelody.getColorClass(x)); +// else pxt.BrowserUtils.addClass(cellRect, "melody-default"); + +// if ((this.sourceBlock_.workspace as any).isFlyout) return; + +// pxsim.pointerEvents.down.forEach(evid => cellRect.addEventListener(evid, (ev: MouseEvent) => { +// this.onNoteSelect(x, y); +// ev.stopPropagation(); +// ev.preventDefault(); +// }, false)); + +// this.cells[x][y] = cellRect; +// } + +// private togglePlay() { +// if (!this.isPlaying) { +// this.isPlaying = true; +// this.playMelody(); +// } else { +// this.stopMelody(); +// } + +// this.updatePlayButton(); +// } + +// private updatePlayButton() { +// if (this.isPlaying) { +// pxt.BrowserUtils.removeClass(this.playIcon, "play icon"); +// pxt.BrowserUtils.addClass(this.playIcon, "stop icon"); +// } +// else { +// pxt.BrowserUtils.removeClass(this.playIcon, "stop icon"); +// pxt.BrowserUtils.addClass(this.playIcon, "play icon"); +// } +// } + +// private playMelody() { +// if (this.isPlaying) { +// for (let i = 0; i < this.numCol; i++) { +// this.queueToneForColumn(i, i * this.getDuration(), this.getDuration()); +// } +// this.timeouts.push(setTimeout( // call the melody again after it finishes +// () => this.playMelody(), (this.numCol) * this.getDuration())); +// } else { +// this.stopMelody(); +// } +// } + +// private stopMelody() { +// if (this.isPlaying) { +// while (this.timeouts.length) clearTimeout(this.timeouts.shift()); +// pxt.AudioContextManager.stop(); +// this.isPlaying = false; + +// this.cells.forEach(row => row.forEach(cell => pxt.BrowserUtils.removeClass(cell, "playing"))); +// } +// } + +// private showGallery() { +// this.stopMelody(); +// this.updatePlayButton(); +// this.gallery.show((result: string) => { +// if (result) { +// this.melody.parseNotes(result); +// this.gallery.hide(); +// this.toggle.toggle(); +// this.updateFieldLabel(); +// this.updateGrid(); +// } +// }); +// } + +// private hideGallery() { +// this.gallery.hide(); +// } +// } + +// export interface ButtonGroup { +// root: svg.Group; +// cx: number; +// cy: number; +// } + +// const TOGGLE_WIDTH = 200; +// const TOGGLE_HEIGHT = 40; +// const TOGGLE_BORDER_WIDTH = 2; +// const TOGGLE_CORNER_RADIUS = 4; + +// const BUTTON_CORNER_RADIUS = 2; +// const BUTTON_BORDER_WIDTH = 1; +// const BUTTON_BOTTOM_BORDER_WIDTH = 2; + +// interface ToggleProps { +// baseColor: string; +// borderColor: string; +// backgroundColor: string; +// switchColor: string; +// unselectedTextColor: string; +// selectedTextColor: string; + +// leftText: string; +// leftIcon: string; + +// rightText: string; +// rightIcon: string; +// } + +// class Toggle { +// protected leftElement: svg.Group; +// protected leftText: svg.Text; +// protected rightElement: svg.Group; +// protected rightText: svg.Text; + +// protected switch: svg.Rect; +// protected root: svg.Group; +// protected props: ToggleProps; + +// protected isLeft: boolean; +// protected changeHandler: (left: boolean) => void; + +// constructor(parent: svg.SVG, props: Partial) { +// this.props = defaultColors(props); +// this.root = parent.group(); +// this.buildDom(); +// this.isLeft = true; +// } + +// protected buildDom() { +// // Our css minifier mangles animation names so they need to be injected manually +// this.root.style().content(` +// .toggle-left { +// transform: translateX(0px); +// animation: mvleft 0.2s 0s ease; +// } + +// .toggle-right { +// transform: translateX(100px); +// animation: mvright 0.2s 0s ease; +// } + +// @keyframes mvright { +// 0% { +// transform: translateX(0px); +// } +// 100% { +// transform: translateX(100px); +// } +// } + +// @keyframes mvleft { +// 0% { +// transform: translateX(100px); +// } +// 100% { +// transform: translateX(0px); +// } +// } +// `); + + +// // The outer border has an inner-stroke so we need to clip out the outer part +// // because SVG's don't support "inner borders" +// const clip = this.root.def().create("clipPath", "sprite-editor-toggle-border") +// .clipPathUnits(true); + +// clip.draw("rect") +// .at(0, 0) +// .corners(TOGGLE_CORNER_RADIUS / TOGGLE_WIDTH, TOGGLE_CORNER_RADIUS / TOGGLE_HEIGHT) +// .size(1, 1); + +// // Draw the outer border +// this.root.draw("rect") +// .size(TOGGLE_WIDTH, TOGGLE_HEIGHT) +// .fill(this.props.baseColor) +// .stroke(this.props.borderColor, TOGGLE_BORDER_WIDTH * 2) +// .corners(TOGGLE_CORNER_RADIUS, TOGGLE_CORNER_RADIUS) +// .clipPath("url(#sprite-editor-toggle-border)"); + + +// // Draw the background +// this.root.draw("rect") +// .at(TOGGLE_BORDER_WIDTH, TOGGLE_BORDER_WIDTH) +// .size(TOGGLE_WIDTH - TOGGLE_BORDER_WIDTH * 2, TOGGLE_HEIGHT - TOGGLE_BORDER_WIDTH * 2) +// .fill(this.props.backgroundColor) +// .corners(TOGGLE_CORNER_RADIUS, TOGGLE_CORNER_RADIUS); + +// // Draw the switch +// this.switch = this.root.draw("rect") +// .at(TOGGLE_BORDER_WIDTH, TOGGLE_BORDER_WIDTH) +// .size((TOGGLE_WIDTH - TOGGLE_BORDER_WIDTH * 2) / 2, TOGGLE_HEIGHT - TOGGLE_BORDER_WIDTH * 2) +// .fill(this.props.switchColor) +// .corners(TOGGLE_CORNER_RADIUS, TOGGLE_CORNER_RADIUS); + +// // Draw the left option +// this.leftElement = this.root.group(); +// this.leftText = mkText(this.props.leftText) +// .appendClass("sprite-editor-text") +// .fill(this.props.selectedTextColor); +// this.leftElement.appendChild(this.leftText); + +// // Draw the right option +// this.rightElement = this.root.group(); +// this.rightText = mkText(this.props.rightText) +// .appendClass("sprite-editor-text") +// .fill(this.props.unselectedTextColor); +// this.rightElement.appendChild(this.rightText); + +// this.root.onClick(() => this.toggle()); +// } + +// toggle(quiet = false) { +// if (this.isLeft) { +// this.switch.removeClass("toggle-left"); +// this.switch.appendClass("toggle-right"); +// this.leftText.fill(this.props.unselectedTextColor); +// this.rightText.fill(this.props.selectedTextColor); +// } +// else { +// this.switch.removeClass("toggle-right"); +// this.switch.appendClass("toggle-left"); +// this.leftText.fill(this.props.selectedTextColor); +// this.rightText.fill(this.props.unselectedTextColor); +// } +// this.isLeft = !this.isLeft; + +// if (!quiet && this.changeHandler) { +// this.changeHandler(this.isLeft); +// } +// } + +// onStateChange(handler: (left: boolean) => void) { +// this.changeHandler = handler; +// } + +// layout() { +// const centerOffset = (TOGGLE_WIDTH - TOGGLE_BORDER_WIDTH * 2) / 4; +// this.leftText.moveTo(centerOffset + TOGGLE_BORDER_WIDTH, TOGGLE_HEIGHT / 2); +// this.rightText.moveTo(TOGGLE_WIDTH - TOGGLE_BORDER_WIDTH - centerOffset, TOGGLE_HEIGHT / 2) +// } + +// translate(x: number, y: number) { +// this.root.translate(x, y); +// } + +// height() { +// return TOGGLE_HEIGHT; +// } + +// width() { +// return TOGGLE_WIDTH; +// } +// } + +// function mkText(text: string) { +// return new svg.Text(text) +// .anchor("middle") +// .setAttribute("dominant-baseline", "middle") +// .setAttribute("dy", (pxt.BrowserUtils.isIE() || pxt.BrowserUtils.isEdge()) ? "0.3em" : "0.1em") +// } + +// function defaultColors(props: Partial): ToggleProps { +// if (!props.baseColor) props.baseColor = "#e95153"; +// if (!props.backgroundColor) props.backgroundColor = "rgba(52,73,94,.2)"; +// if (!props.borderColor) props.borderColor = "rgba(52,73,94,.4)"; +// if (!props.selectedTextColor) props.selectedTextColor = props.baseColor; +// if (!props.unselectedTextColor) props.unselectedTextColor = "hsla(0,0%,100%,.9)"; +// if (!props.switchColor) props.switchColor = "#ffffff"; + +// return props as ToggleProps; +// } + +// /** +// * This gets the placeholder color which is embedded in the rendered svg. These are overridden +// * by the css class we set on each rect from pxtmelody.getColorClass and will only be seen +// * if the svg is taken without the corresponding css (e.g. in a blockly snapshot) +// */ +// function getPlaceholderColor(row: number): string { +// switch (row) { +// case 0: return "#A80000"; // Middle C +// case 1: return "#D83B01"; // Middle D +// case 2: return "#FFB900"; // Middle E +// case 3: return "#107C10"; // Middle F +// case 4: return "#008272"; // Middle G +// case 5: return "#0078D7"; // Middle A +// case 6: return "#5C2D91"; // Middle B +// case 7: return "#B4009E"; // Tenor C +// } +// return "#DCDCDC"; +// } +// } diff --git a/newblocks/fields/field_musiceditor.ts b/newblocks/fields/field_musiceditor.ts new file mode 100644 index 000000000000..d11b804e7aef --- /dev/null +++ b/newblocks/fields/field_musiceditor.ts @@ -0,0 +1,136 @@ +// /// + +// import * as Blockly from "blockly"; + + +// namespace pxtblockly { +// import svg = pxt.svgUtil; + +// export interface FieldMusicEditorOptions { +// } + +// interface ParsedFieldMusicEditorOptions { +// } + +// const PREVIEW_HEIGHT = 32; +// const X_PADDING = 5; +// const Y_PADDING = 1; +// const BG_PADDING = 4; +// const BG_HEIGHT = BG_PADDING * 2 + PREVIEW_HEIGHT; +// const TOTAL_HEIGHT = Y_PADDING * 2 + BG_PADDING * 2 + PREVIEW_HEIGHT; + +// export class FieldMusicEditor extends FieldAssetEditor { +// protected getAssetType(): pxt.AssetType { +// return pxt.AssetType.Song; +// } + +// protected createNewAsset(text?: string): pxt.Asset { +// const project = pxt.react.getTilemapProject(); +// if (text) { +// const asset = pxt.lookupProjectAssetByTSReference(text, project); + +// if (asset) return asset; +// } + +// if (this.getBlockData()) { +// return project.lookupAsset(pxt.AssetType.Song, this.getBlockData()); +// } + +// let song: pxt.assets.music.Song; + +// if (text) { +// const match = /^\s*hex\s*`([a-fA-F0-9]+)`\s*(?:;?)\s*$/.exec(text); + +// if (match) { +// song = pxt.assets.music.decodeSongFromHex(match[1]); +// } +// } +// else { +// song = pxt.assets.music.getEmptySong(2); +// } + +// if (!song) { +// this.isGreyBlock = true; +// this.valueText = text; +// return undefined; +// } +// else { +// // Restore all of the unused tracks +// pxt.assets.music.inflateSong(song); +// } + +// const newAsset: pxt.Song = { +// internalID: -1, +// id: this.sourceBlock_.id, +// type: pxt.AssetType.Song, +// meta: { +// }, +// song +// }; + +// return newAsset; +// } + +// render_() { +// super.render_(); + +// if (!this.isGreyBlock) { +// this.size_.height = TOTAL_HEIGHT; +// this.size_.width = X_PADDING * 2 + BG_PADDING * 2 + this.previewWidth(); +// } +// } + +// protected getValueText(): string { +// if (this.asset && !this.isTemporaryAsset()) { +// return pxt.getTSReferenceForAsset(this.asset); +// } +// return this.asset ? `hex\`${pxt.assets.music.encodeSongToHex((this.asset as pxt.Song).song)}\`` : ""; +// } + +// protected parseFieldOptions(opts: FieldMusicEditorOptions): ParsedFieldMusicEditorOptions { +// return {}; +// } + +// protected redrawPreview() { +// if (!this.fieldGroup_) return; +// pxsim.U.clear(this.fieldGroup_); + +// if (this.isGreyBlock) { +// super.redrawPreview(); +// return; +// } + +// const totalWidth = X_PADDING * 2 + BG_PADDING * 2 + this.previewWidth(); + +// const bg = new svg.Rect() +// .at(X_PADDING, Y_PADDING) +// .size(BG_PADDING * 2 + this.previewWidth(), BG_HEIGHT) +// .setClass("blocklySpriteField") +// .stroke("#898989", 1) +// .corner(4); + +// this.fieldGroup_.appendChild(bg.el); + +// if (this.asset) { +// const dataURI = songToDataURI((this.asset as pxt.Song).song, this.previewWidth(), PREVIEW_HEIGHT, this.lightMode); + +// if (dataURI) { +// const img = new svg.Image() +// .src(dataURI) +// .at(X_PADDING + BG_PADDING, Y_PADDING + BG_PADDING) +// .size(this.previewWidth(), PREVIEW_HEIGHT); +// this.fieldGroup_.appendChild(img.el); +// } +// } + +// if (this.size_?.width != totalWidth) { +// this.forceRerender(); +// } +// } + +// protected previewWidth() { +// const measures = this.asset ? (this.asset as pxt.Song).song.measures : 2; +// return measures * PREVIEW_HEIGHT; +// } +// } +// } diff --git a/newblocks/fields/field_note.ts b/newblocks/fields/field_note.ts new file mode 100644 index 000000000000..d8341fa84c17 --- /dev/null +++ b/newblocks/fields/field_note.ts @@ -0,0 +1,643 @@ +// /// + +// import * as Blockly from "blockly"; + +// namespace pxtblockly { + +// enum Note { +// C = 262, +// CSharp = 277, +// D = 294, +// Eb = 311, +// E = 330, +// F = 349, +// FSharp = 370, +// G = 392, +// GSharp = 415, +// A = 440, +// Bb = 466, +// B = 494, +// C3 = 131, +// CSharp3 = 139, +// D3 = 147, +// Eb3 = 156, +// E3 = 165, +// F3 = 175, +// FSharp3 = 185, +// G3 = 196, +// GSharp3 = 208, +// A3 = 220, +// Bb3 = 233, +// B3 = 247, +// C4 = 262, +// CSharp4 = 277, +// D4 = 294, +// Eb4 = 311, +// E4 = 330, +// F4 = 349, +// FSharp4 = 370, +// G4 = 392, +// GSharp4 = 415, +// A4 = 440, +// Bb4 = 466, +// B4 = 494, +// C5 = 523, +// CSharp5 = 555, +// D5 = 587, +// Eb5 = 622, +// E5 = 659, +// F5 = 698, +// FSharp5 = 740, +// G5 = 784, +// GSharp5 = 831, +// A5 = 880, +// Bb5 = 932, +// B5 = 988, +// C6 = 1047, +// CSharp6 = 1109, +// D6 = 1175, +// Eb6 = 1245, +// E6 = 1319, +// F6 = 1397, +// FSharp6 = 1480, +// G6 = 1568, +// GSharp6 = 1568, +// A6 = 1760, +// Bb6 = 1865, +// B6 = 1976, +// C7 = 2093 +// } + +// interface NoteData { +// name: string, +// prefixedName: string, +// altPrefixedName?: string, +// freq: number +// } + +// export interface FieldNoteOptions extends Blockly.FieldCustomOptions { +// editorColour?: string; +// minNote?: string; +// maxNote?: string; +// eps?: string; +// } + +// export class FieldNote extends Blockly.FieldNumber implements Blockly.FieldCustom { +// public isFieldCustom_ = true; +// public SERIALIZABLE = true; +// public isTextValid_ = true; +// private static Notes: {[key: number]: NoteData}; + +// protected static readonly keyWidth = 22; +// protected static readonly keyHeight = 90; +// protected static readonly labelHeight = 24; +// protected static readonly prevNextHeight = 20; +// protected static readonly notesPerOctave = 12; +// protected static readonly blackKeysPerOctave = 5; + +// /** +// * default number of piano keys +// */ +// protected nKeys_ = 36; +// protected minNote_ = 28; +// protected maxNote_ = 63; +// /** Absolute error for note frequency identification (Hz) **/ +// protected eps = 2; + +// protected primaryColour: string; +// protected borderColour: string; +// protected isExpanded: Boolean; +// protected totalPlayCount: number; + +// protected currentPage: number; +// protected piano: HTMLDivElement[]; +// protected noteLabel: HTMLDivElement; +// protected currentSelectedKey: HTMLDivElement; + +// constructor(text: string, params: FieldNoteOptions, validator?: Function) { +// // passing null as we need more state before we properly set value. +// super(null, 0, null, null, validator); +// this.setSpellcheck(false); +// this.prepareNotes(); + +// this.isExpanded = false; +// this.currentPage = 0; +// this.totalPlayCount = 0; + +// if (params.editorColour) { +// this.primaryColour = pxtblockly.parseColour(params.editorColour); +// this.borderColour = Blockly.utils.colour.darken(this.primaryColour, 0.2); +// } + +// const eps = parseInt(params.eps); +// if (!Number.isNaN(eps) && eps >= 0) { +// this.eps = eps; +// } + +// const minNote = parseInt(params.minNote) || this.minNote_; +// const maxNote = parseInt(params.maxNote) || this.maxNote_; +// if (minNote >= 28 && maxNote <= 75 && maxNote > minNote) { +// this.minNote_ = minNote; +// this.maxNote_ = maxNote; +// this.nKeys_ = this.maxNote_ - this.minNote_ + 1; +// } +// this.setValue(text); +// } + +// /** +// * Ensure that only a non negative number may be entered. +// * @param {string} text The user's text. +// * @return A string representing a valid positive number, or null if invalid. +// */ +// doClassValidation_(text: string) { +// // accommodate note strings like "Note.GSharp5" as well as numbers +// const match = /^Note\.(.+)$/.exec(text); +// const noteName: any = (match && match.length > 1) ? match[1] : null; +// text = Note[noteName] ? Note[noteName] : String(parseFloat(text || "0")); + +// if (text === null) { +// return null; +// } + +// const n = parseFloat(text || "0"); +// if (isNaN(n) || n < 0) { +// return null; +// } + +// const showDecimal = Math.floor(n) != n; +// return "" + n.toFixed(showDecimal ? 2 : 0); +// } + +// /** +// * Return the current note frequency. +// * @return Current note in string format. +// */ +// getValue(): string { +// return this.value_ + ""; +// } + +// /** +// * Called by setValue if the text input is valid. Updates the value of the +// * field, and updates the text of the field if it is not currently being +// * edited (i.e. handled by the htmlInput_). +// * @param {string} note The new note in string format. +// */ +// doValueUpdate_(note: string) { +// if (isNaN(Number(note)) || Number(note) < 0) +// return; + +// if (this.sourceBlock_ && Blockly.Events.isEnabled() && this.value_ != note) { +// Blockly.Events.fire( +// new Blockly.Events.Change( +// this.sourceBlock_, +// "field", +// this.name, +// this.value_, +// note +// ) +// ); +// } + +// this.value_ = note; +// this.refreshText(); +// } + +// /** +// * Get the text from this field +// * @return Current text. +// */ +// getText(): string { +// if (this.isExpanded) { +// return "" + this.value_; +// } else { +// const note = +this.value_; +// for (let i = 0; i < this.nKeys_; i++) { +// if (Math.abs(this.getKeyFreq(i + this.minNote_) - note) < this.eps) { +// return this.getKeyName(i + this.minNote_); +// } +// } +// let text = note.toString(); +// if (!isNaN(note)) +// text += " Hz"; +// return text; +// } +// } + +// /** +// * This block shows up differently when it's being edited; +// * on any transition between `editing <--> not-editing` +// * or other change in state, +// * refresh the text to get back into a valid state. +// **/ +// protected refreshText() { +// this.forceRerender(); +// } + +// onHtmlInputChange_(e: any) { +// super.onHtmlInputChange_(e); +// Blockly.DropDownDiv.hideWithoutAnimation(); +// (this as any).htmlInput_.focus(); +// } + +// onFinishEditing_(text: string) { +// this.refreshText(); +// } + +// protected onHide() { +// this.isExpanded = false; +// this.refreshText() +// }; + +// /** +// * Create a piano under the note field. +// */ +// showEditor_(e: Event): void { +// this.isExpanded = true; +// this.updateColor(); + +// // If there is an existing drop-down someone else owns, hide it immediately and clear it. +// Blockly.DropDownDiv.hideWithoutAnimation(); +// Blockly.DropDownDiv.clearContent(); + +// const isMobile = pxt.BrowserUtils.isMobile() || pxt.BrowserUtils.isIOS(); +// // invoke FieldTextInputs showeditor, so we can set quiet explicitly / not have a pop up dialogue +// (FieldNote as any).superClass_.showEditor_.call(this, e, /** quiet **/ isMobile, /** readonly **/ isMobile); +// this.refreshText(); + +// // save all changes in the same group of events +// Blockly.Events.setGroup(true); + +// this.piano = []; +// this.currentSelectedKey = undefined; + +// const totalWhiteKeys = this.nKeys_ - (this.nKeys_ / FieldNote.notesPerOctave * FieldNote.blackKeysPerOctave); +// const whiteKeysPerOctave = FieldNote.notesPerOctave - FieldNote.blackKeysPerOctave; +// let pianoWidth = FieldNote.keyWidth * totalWhiteKeys; +// let pianoHeight = FieldNote.keyHeight + FieldNote.labelHeight; + +// const pagination = window.innerWidth < pianoWidth; + +// if (pagination) { +// pianoWidth = whiteKeysPerOctave * FieldNote.keyWidth; +// pianoHeight = FieldNote.keyHeight + FieldNote.labelHeight + FieldNote.prevNextHeight; +// } + +// const pianoDiv = createStyledDiv( +// "blocklyPianoDiv", +// `width: ${pianoWidth}px; +// height: ${pianoHeight}px;` +// ); +// Blockly.DropDownDiv.getContentDiv().appendChild(pianoDiv); + +// // render note label +// this.noteLabel = createStyledDiv( +// "blocklyNoteLabel", +// `top: ${FieldNote.keyHeight}px; +// width: ${pianoWidth}px; +// background-color: ${this.primaryColour}; +// border-color: ${this.primaryColour};` +// ); +// pianoDiv.appendChild(this.noteLabel); +// this.noteLabel.textContent = "-"; + +// let startingPage = 0; +// for (let i = 0; i < this.nKeys_; i++) { +// const currentOctave = Math.floor(i / FieldNote.notesPerOctave); +// let position = this.getPosition(i + this.minNote_); + +// // modify original position in pagination +// if (pagination && i >= FieldNote.notesPerOctave) +// position -= whiteKeysPerOctave * currentOctave * FieldNote.keyWidth; + +// const key = this.getKeyDiv(i + this.minNote_, position); +// this.piano.push(key); +// pianoDiv.appendChild(key); + +// // if the current value is within eps of this note, select it. +// if (Math.abs(this.getKeyFreq(i + this.minNote_) - Number(this.getValue())) < this.eps) { +// pxt.BrowserUtils.addClass(key, "selected"); +// this.currentSelectedKey = key; +// startingPage = currentOctave; +// } +// } + +// if (pagination) { +// this.setPage(startingPage); +// pianoDiv.appendChild(this.getNextPrevDiv(/** prev **/ true, pianoWidth)); +// pianoDiv.appendChild(this.getNextPrevDiv(/** prev **/ false, pianoWidth)); +// } + +// Blockly.DropDownDiv.setColour(this.primaryColour, this.borderColour); +// Blockly.DropDownDiv.showPositionedByBlock(this, this.sourceBlock_ as Blockly.BlockSvg, () => this.onHide()); +// } + +// protected playKey(key: HTMLDivElement, frequency: number) { +// const notePlayID = ++this.totalPlayCount; + +// if (this.currentSelectedKey !== key) { +// if (this.currentSelectedKey) +// pxt.BrowserUtils.removeClass(this.currentSelectedKey, "selected"); +// pxt.BrowserUtils.addClass(key, "selected"); +// this.setValue(frequency); +// } + +// this.currentSelectedKey = key; +// /** +// * force a rerender of the preview; other attempts at changing the value +// * do not show up on the block itself until after the fieldeditor is closed, +// * as it is currently in an editable state. +// **/ +// (this as any).htmlInput_.value = this.getText(); + +// pxt.AudioContextManager.tone(frequency); +// setTimeout(() => { +// // Clear the sound if it is still playing after 300ms +// if (this.totalPlayCount == notePlayID) pxt.AudioContextManager.stop(); +// }, 300); +// } + +// /** +// * Close the note picker if this input is being deleted. +// */ +// dispose() { +// Blockly.DropDownDiv.hideIfOwner(this); +// super.dispose() +// } + +// private updateColor() { +// if (this.sourceBlock_.parentBlock_ && (this.sourceBlock_.isShadow() || hasOnlyOneField(this.sourceBlock_))) { +// let b = this.sourceBlock_.parentBlock_ as Blockly.BlockSvg; +// this.primaryColour = b.getColour(); +// this.borderColour = b.getColourTertiary(); +// } +// else { +// this.primaryColour = "#3D3D3D"; +// this.borderColour = "#2A2A2A"; +// } +// } + +// protected setPage(page: number) { +// const pageCount = this.nKeys_ / FieldNote.notesPerOctave; + +// page = Math.max(Math.min(page, pageCount - 1), 0); +// this.noteLabel.textContent = `Octave #${page + 1}`; + +// const firstKeyInOctave = page * FieldNote.notesPerOctave; + +// for (let i = 0; i < this.piano.length; ++i) { +// const isInOctave = i >= firstKeyInOctave && i < firstKeyInOctave + FieldNote.notesPerOctave; +// this.piano[i].style.display = isInOctave ? "block" : "none"; +// } + +// this.currentPage = page; +// }; + +// /** +// * create a DOM to assign a style to the previous and next buttons +// * @param pianoWidth the width of the containing piano +// * @param isPrev true if is previous button, false otherwise +// * @return DOM with the new css style.s +// */ +// protected getNextPrevDiv(isPrev: boolean, pianoWidth: number) { +// const xPosition = isPrev ? 0 : (pianoWidth / 2); +// const yPosition = FieldNote.keyHeight + FieldNote.labelHeight; + +// const output = createStyledDiv( +// "blocklyNotePrevNext", +// `top: ${yPosition}px; +// left: ${xPosition}px; +// width: ${Math.ceil(pianoWidth / 2)}px; +// ${isPrev ? "border-left-color" : "border-right-color"}: ${this.primaryColour}; +// background-color: ${this.primaryColour}; +// border-bottom-color: ${this.primaryColour};` +// ); + +// pxt.BrowserUtils.pointerEvents.down.forEach(ev => { +// Blockly.bindEventWithChecks_( +// output, +// ev, +// this, +// () => this.setPage(isPrev ? this.currentPage - 1 : this.currentPage + 1), +// /** noCaptureIdentifier **/ true +// ); +// }); + +// output.textContent = isPrev ? "<" : ">"; +// return output; +// } + +// protected getKeyDiv(keyInd: number, leftPosition: number) { +// const output = createStyledDiv( +// `blocklyNote ${this.isWhite(keyInd) ? "" : "black"}`, +// `width: ${this.getKeyWidth(keyInd)}px; +// height: ${this.getKeyHeight(keyInd)}px; +// left: ${leftPosition}px; +// border-color: ${this.primaryColour};` +// ); + +// pxt.BrowserUtils.pointerEvents.down.forEach(ev => { +// Blockly.bindEventWithChecks_( +// output, +// ev, +// this, +// () => this.playKey(output, this.getKeyFreq(keyInd)), +// /** noCaptureIdentifier **/ true +// ); +// }); + +// Blockly.bindEventWithChecks_( +// output, +// 'mouseover', +// this, +// () => this.noteLabel.textContent = this.getKeyName(keyInd), +// /** noCaptureIdentifier **/ true +// ); + +// return output; +// } + +// /** +// * @param idx index of the key +// * @return true if idx is white +// */ +// protected isWhite(idx: number): boolean { +// idx += 8; +// switch (idx % 12) { +// case 1: case 3: case 6: +// case 8: case 10: +// return false; +// default: +// return true; +// } +// } + +// protected whiteKeysBefore(idx: number): number { +// idx += 8; +// switch (idx % 12) { +// case 0: return 0; +// case 1: +// case 2: +// return 1; +// case 3: +// case 4: +// return 2; +// case 5: +// return 3; +// case 6: +// case 7: +// return 4; +// case 8: +// case 9: +// return 5; +// case 10: +// case 11: +// return 6 +// } +// return -1; +// } + +// /** +// * get width of the piano key +// * @param idx index of the key +// * @return width of the key +// */ +// protected getKeyWidth(idx: number): number { +// if (this.isWhite(idx)) +// return FieldNote.keyWidth; +// return FieldNote.keyWidth / 2; +// } + +// /** +// * get height of the piano key +// * @param idx index of the key +// * @return height of the key +// */ +// protected getKeyHeight(idx: number): number { +// if (this.isWhite(idx)) +// return FieldNote.keyHeight; +// return FieldNote.keyHeight / 2; +// } + +// protected getKeyFreq(keyIndex: number) { +// return this.getKeyNoteData(keyIndex).freq; +// } + +// protected getKeyName(keyIndex: number) { +// const note = this.getKeyNoteData(keyIndex); +// let name = note.prefixedName; +// if (this.nKeys_ <= FieldNote.notesPerOctave) { +// // special case: one octave +// name = note.name; +// } else if (this.minNote_ >= 28 && this.maxNote_ <= 63) { +// // special case: centered +// name = note.altPrefixedName || name; +// } +// return name; +// } + +// private getKeyNoteData(keyIndex: number) { +// return FieldNote.Notes[keyIndex]; +// } + +// /** +// * get the position of the key in the piano +// * @param idx index of the key +// * @return position of the key +// */ +// protected getPosition(idx: number): number { +// if (idx === this.minNote_) return 0; + +// const blackKeyOffset = (FieldNote.keyWidth / 4); + +// const startOctave = Math.floor((this.minNote_ + 8) / FieldNote.notesPerOctave); +// const currentOctave = Math.floor((idx + 8) / FieldNote.notesPerOctave); + +// let startOffset = this.whiteKeysBefore(this.minNote_) * FieldNote.keyWidth; +// if (!this.isWhite(this.minNote_)) { +// startOffset -= blackKeyOffset; +// } + + +// if (currentOctave > startOctave) { +// const octaveWidth = FieldNote.keyWidth * 7; +// const firstOctaveWidth = octaveWidth - startOffset; +// const octaveStart = firstOctaveWidth + (currentOctave - startOctave - 1) * octaveWidth; + +// return this.whiteKeysBefore(idx) * FieldNote.keyWidth + octaveStart - (this.isWhite(idx) ? 0 : blackKeyOffset); + +// } +// else { +// return this.whiteKeysBefore(idx) * FieldNote.keyWidth - startOffset - (this.isWhite(idx) ? 0 : blackKeyOffset); +// } +// } + +// private prepareNotes() { +// if (!FieldNote.Notes) { +// FieldNote.Notes = { +// 28: { name: lf("{id:note}C"), prefixedName: lf("Low C"), freq: 131 }, +// 29: { name: lf("C#"), prefixedName: lf("Low C#"), freq: 139 }, +// 30: { name: lf("{id:note}D"), prefixedName: lf("Low D"), freq: 147 }, +// 31: { name: lf("D#"), prefixedName: lf("Low D#"), freq: 156 }, +// 32: { name: lf("{id:note}E"), prefixedName: lf("Low E"), freq: 165 }, +// 33: { name: lf("{id:note}F"), prefixedName: lf("Low F"), freq: 175 }, +// 34: { name: lf("F#"), prefixedName: lf("Low F#"), freq: 185 }, +// 35: { name: lf("{id:note}G"), prefixedName: lf("Low G"), freq: 196 }, +// 36: { name: lf("G#"), prefixedName: lf("Low G#"), freq: 208 }, +// 37: { name: lf("{id:note}A"), prefixedName: lf("Low A"), freq: 220 }, +// 38: { name: lf("A#"), prefixedName: lf("Low A#"), freq: 233 }, +// 39: { name: lf("{id:note}B"), prefixedName: lf("Low B"), freq: 247 }, + +// 40: { name: lf("{id:note}C"), prefixedName: lf("Middle C"), freq: 262 }, +// 41: { name: lf("C#"), prefixedName: lf("Middle C#"), freq: 277 }, +// 42: { name: lf("{id:note}D"), prefixedName: lf("Middle D"), freq: 294 }, +// 43: { name: lf("D#"), prefixedName: lf("Middle D#"), freq: 311 }, +// 44: { name: lf("{id:note}E"), prefixedName: lf("Middle E"), freq: 330 }, +// 45: { name: lf("{id:note}F"), prefixedName: lf("Middle F"), freq: 349 }, +// 46: { name: lf("F#"), prefixedName: lf("Middle F#"), freq: 370 }, +// 47: { name: lf("{id:note}G"), prefixedName: lf("Middle G"), freq: 392 }, +// 48: { name: lf("G#"), prefixedName: lf("Middle G#"), freq: 415 }, +// 49: { name: lf("{id:note}A"), prefixedName: lf("Middle A"), freq: 440 }, +// 50: { name: lf("A#"), prefixedName: lf("Middle A#"), freq: 466 }, +// 51: { name: lf("{id:note}B"), prefixedName: lf("Middle B"), freq: 494 }, + +// 52: { name: lf("{id:note}C"), prefixedName: lf("Tenor C"), altPrefixedName: lf("High C"), freq: 523 }, +// 53: { name: lf("C#"), prefixedName: lf("Tenor C#"), altPrefixedName: lf("High C#"), freq: 554 }, +// 54: { name: lf("{id:note}D"), prefixedName: lf("Tenor D"), altPrefixedName: lf("High D"), freq: 587 }, +// 55: { name: lf("D#"), prefixedName: lf("Tenor D#"), altPrefixedName: lf("High D#"), freq: 622 }, +// 56: { name: lf("{id:note}E"), prefixedName: lf("Tenor E"), altPrefixedName: lf("High E"), freq: 659 }, +// 57: { name: lf("{id:note}F"), prefixedName: lf("Tenor F"), altPrefixedName: lf("High F"), freq: 698 }, +// 58: { name: lf("F#"), prefixedName: lf("Tenor F#"), altPrefixedName: lf("High F#"), freq: 740 }, +// 59: { name: lf("{id:note}G"), prefixedName: lf("Tenor G"), altPrefixedName: lf("High G"), freq: 784 }, +// 60: { name: lf("G#"), prefixedName: lf("Tenor G#"), altPrefixedName: lf("High G#"), freq: 831 }, +// 61: { name: lf("{id:note}A"), prefixedName: lf("Tenor A"), altPrefixedName: lf("High A"), freq: 880 }, +// 62: { name: lf("A#"), prefixedName: lf("Tenor A#"), altPrefixedName: lf("High A#"), freq: 932 }, +// 63: { name: lf("{id:note}B"), prefixedName: lf("Tenor B"), altPrefixedName: lf("High B"), freq: 988 }, + +// 64: { name: lf("{id:note}C"), prefixedName: lf("High C"), freq: 1046 }, +// 65: { name: lf("C#"), prefixedName: lf("High C#"), freq: 1109 }, +// 66: { name: lf("{id:note}D"), prefixedName: lf("High D"), freq: 1175 }, +// 67: { name: lf("D#"), prefixedName: lf("High D#"), freq: 1245 }, +// 68: { name: lf("{id:note}E"), prefixedName: lf("High E"), freq: 1319 }, +// 69: { name: lf("{id:note}F"), prefixedName: lf("High F"), freq: 1397 }, +// 70: { name: lf("F#"), prefixedName: lf("High F#"), freq: 1478 }, +// 71: { name: lf("{id:note}G"), prefixedName: lf("High G"), freq: 1568 }, +// 72: { name: lf("G#"), prefixedName: lf("High G#"), freq: 1661 }, +// 73: { name: lf("{id:note}A"), prefixedName: lf("High A"), freq: 1760 }, +// 74: { name: lf("A#"), prefixedName: lf("High A#"), freq: 1865 }, +// 75: { name: lf("{id:note}B"), prefixedName: lf("High B"), freq: 1976 } +// } +// } +// } +// } + +// function hasOnlyOneField(block: Blockly.Block) { +// return block.inputList.length === 1 && block.inputList[0].fieldRow.length === 1; +// } + +// function createStyledDiv(className: string, style: string) { +// const output = document.createElement("div"); +// pxt.BrowserUtils.addClass(output, className); +// output.setAttribute("style", style.replace(/\s+/g, " ")); +// return output; +// } +// } diff --git a/newblocks/fields/field_numberdropdown.ts b/newblocks/fields/field_numberdropdown.ts new file mode 100644 index 000000000000..29673cb2bd32 --- /dev/null +++ b/newblocks/fields/field_numberdropdown.ts @@ -0,0 +1,46 @@ +// /// + +// import * as Blockly from "blockly"; + +// // common time options -- do not remove +// // lf("100 ms") +// // lf("200 ms") +// // lf("500 ms") +// // lf("1 second") +// // lf("2 seconds") +// // lf("5 seconds") +// // lf("1 minute") +// // lf("1 hour") + +// namespace pxtblockly { + +// export interface FieldNumberDropdownOptions extends Blockly.FieldCustomDropdownOptions { +// min?: number; +// max?: number; +// precision?: any; +// } + +// export class FieldNumberDropdown extends Blockly.FieldNumberDropdown implements Blockly.FieldCustom { +// public isFieldCustom_ = true; + +// private menuGenerator_: any; + +// constructor(value: number | string, options: FieldNumberDropdownOptions, opt_validator?: Function) { +// super(value, options.data, options.min, options.max, options.precision, opt_validator); +// } + +// getOptions() { +// let newOptions: string[][]; +// if (this.menuGenerator_) { +// newOptions = JSON.parse(this.menuGenerator_).map((x: number | string[]) => { +// if (typeof x == 'object') { +// return [pxt.Util.rlf(x[0]), x[1]] +// } else { +// return [String(x), String(x)] +// } +// }); +// } +// return newOptions; +// } +// } +// } \ No newline at end of file diff --git a/newblocks/fields/field_position.ts b/newblocks/fields/field_position.ts new file mode 100644 index 000000000000..73b8083fa0f8 --- /dev/null +++ b/newblocks/fields/field_position.ts @@ -0,0 +1,243 @@ +// /// + +// import * as Blockly from "blockly"; + +// namespace pxtblockly { + +// export interface FieldPositionOptions extends Blockly.FieldCustomOptions { +// min?: string; +// max?: string; +// screenWidth?: number; +// screenHeight?: number; +// xInputName?: string; +// yInputName?: string; +// } + +// export class FieldPosition extends Blockly.FieldSlider implements Blockly.FieldCustom { +// public isFieldCustom_ = true; +// private params: FieldPositionOptions; +// private selectorDiv_: HTMLElement; +// private resetCrosshair: () => void; + +// constructor(text: string, params: FieldPositionOptions, validator?: Function) { +// super(text, '0', '100', '1', '100', 'Value', validator); +// this.params = params; +// if (!this.params.screenHeight) this.params.screenHeight = 120; +// if (!this.params.screenWidth) this.params.screenWidth = 160; +// if (!this.params.xInputName) this.params.xInputName = "x"; +// if (!this.params.yInputName) this.params.yInputName = "y" + +// if (this.params.min) this.min_ = parseInt(this.params.min) +// if (this.params.max) this.max_ = parseInt(this.params.max) +// } + +// showEditor_(_opt_e?: Event) { +// // Find out which field we're in (x or y) and set the appropriate max. +// const xField = this.getFieldByName(this.params.xInputName); +// if (xField === this) { +// this.max_ = this.params.screenWidth; +// this.labelText_ = this.params.xInputName; +// } +// const yField = this.getFieldByName(this.params.yInputName); +// if (yField === this) { +// this.max_ = this.params.screenHeight; +// this.labelText_ = this.params.yInputName; +// } + +// // Call super to render the slider and show the dropdown div +// super.showEditor_(_opt_e); + +// // Now render the screen in the dropdown div below the slider +// this.renderScreenPicker(); +// } + +// doValueUpdate_(value: string) { +// super.doValueUpdate_(value); +// if (this.resetCrosshair) this.resetCrosshair(); +// } + +// protected renderScreenPicker() { +// let contentDiv = Blockly.DropDownDiv.getContentDiv() as HTMLDivElement; +// this.selectorDiv_ = document.createElement('div'); +// this.selectorDiv_.className = "blocklyCanvasOverlayOuter"; +// contentDiv.appendChild(this.selectorDiv_); + +// const canvasOverlayDiv = document.createElement('div'); +// canvasOverlayDiv.className = 'blocklyCanvasOverlayDiv'; +// this.selectorDiv_.appendChild(canvasOverlayDiv); + +// const crossX = document.createElement('div'); +// crossX.className = 'cross-x'; +// canvasOverlayDiv.appendChild(crossX); +// const crossY = document.createElement('div'); +// crossY.className = 'cross-y'; +// canvasOverlayDiv.appendChild(crossY); +// const label = document.createElement('div'); +// label.className = 'label' +// canvasOverlayDiv.appendChild(label); + +// const width = this.params.screenWidth * 1.5; +// const height = this.params.screenHeight * 1.5; + +// canvasOverlayDiv.style.height = height + 'px'; +// canvasOverlayDiv.style.width = width + 'px'; + +// // The slider is set to a fixed width, so we have to resize it +// // to match the screen size +// const slider = contentDiv.getElementsByClassName("goog-slider-horizontal")[0] as HTMLDivElement; +// if (slider) { +// slider.style.width = width + "px"; + +// // Because we resized the slider, we need to update the handle position. The closure +// // slider won't update unless the value changes so change it and un-change it +// const value = parseFloat(this.getValue()); + +// if (!isNaN(value) && value > this.getMin()) { +// this.setValue((value - 1) + ""); +// this.setValue(value + ""); +// } +// } + +// const setPos = (x: number, y: number) => { +// x = Math.round(Math.max(0, Math.min(width, x))); +// y = Math.round(Math.max(0, Math.min(height, y))); + +// crossX.style.top = y + 'px'; +// crossY.style.left = x + 'px'; + +// x = Math.round(Math.max(0, Math.min(this.params.screenWidth, x / width * this.params.screenWidth))); +// y = Math.round(Math.max(0, Math.min(this.params.screenHeight, y / height * this.params.screenHeight))); + +// // Check to see if label exists instead of showing NaN +// if(isNaN(x)) { +// label.textContent = `${this.params.yInputName}=${y}`; +// } +// else if(isNaN(y)) { +// label.textContent = `${this.params.xInputName}=${x}`; +// } +// else { +// label.textContent = `${this.params.xInputName}=${x} ${this.params.yInputName}=${y}`; +// } + +// // Position the label so that it doesn't go outside the screen bounds +// const bb = label.getBoundingClientRect(); +// if (x > this.params.screenWidth / 2) { +// label.style.left = (x * (width / this.params.screenWidth) - bb.width - 8) + 'px'; +// } +// else { +// label.style.left = (x * (width / this.params.screenWidth) + 4) + 'px'; +// } + +// if (y > this.params.screenHeight / 2) { +// label.style.top = (y * (height / this.params.screenHeight) - bb.height - 6) + "px" +// } +// else { +// label.style.top = (y * (height / this.params.screenHeight)) + 'px'; +// } +// } + +// // Position initial crossX and crossY +// this.resetCrosshair = () => { +// const { currentX, currentY } = this.getXY(); +// setPos( +// currentX / this.params.screenWidth * width, +// currentY / this.params.screenHeight * height); +// }; + +// this.resetCrosshair(); + + +// Blockly.bindEvent_(this.selectorDiv_, 'mousemove', this, (e: MouseEvent) => { +// const bb = canvasOverlayDiv.getBoundingClientRect(); +// const x = e.clientX - bb.left; +// const y = e.clientY - bb.top; + +// setPos(x, y); +// }); + +// Blockly.bindEvent_(this.selectorDiv_, 'mouseleave', this, this.resetCrosshair); + +// Blockly.bindEvent_(this.selectorDiv_, 'click', this, (e: MouseEvent) => { +// const bb = canvasOverlayDiv.getBoundingClientRect(); +// const x = e.clientX - bb.left; +// const y = e.clientY - bb.top; + +// const normalizedX = Math.round(x / width * this.params.screenWidth); +// const normalizedY = Math.round(y / height * this.params.screenHeight); + +// this.close(); +// this.setXY(normalizedX, normalizedY); +// }); +// } + +// private resizeHandler() { +// this.close(); +// } + +// private setXY(x: number, y: number) { +// const xField = this.getFieldByName(this.params.xInputName); +// if (xField && typeof xField.getValue() == "number") { +// xField.setValue(String(x)); +// } +// const yField = this.getFieldByName(this.params.yInputName); +// if (yField && typeof yField.getValue() == "number") { +// yField.setValue(String(y)); +// } +// } + +// private getFieldByName(name: string) { +// const parentBlock = this.sourceBlock_.parentBlock_; +// if (!parentBlock) return undefined; // warn +// for (let i = 0; i < parentBlock.inputList.length; i++) { +// const input = parentBlock.inputList[i]; +// if (input.name === name) { +// return this.getTargetField(input); +// } +// } +// return undefined; +// } + +// private getXY() { +// let currentX: string; +// let currentY: string; +// const xField = this.getFieldByName(this.params.xInputName); +// if (xField) currentX = xField.getValue(); +// const yField = this.getFieldByName(this.params.yInputName); +// if (yField) currentY = yField.getValue(); + +// return { currentX: parseInt(currentX), currentY: parseInt(currentY) }; +// } + +// private getTargetField(input: Blockly.Input) { +// const targetBlock = input.connection.targetBlock(); +// if (!targetBlock) return null; +// const targetInput = targetBlock.inputList[0]; +// if (!targetInput) return null; +// const targetField = targetInput.fieldRow[0]; +// return targetField; +// } + +// widgetDispose_() { +// const that = this; +// (Blockly.FieldNumber as any).superClass_.widgetDispose_.call(that); +// that.close(true); +// } + +// private close(skipWidget?: boolean) { +// if (!skipWidget) { +// Blockly.WidgetDiv.hideIfOwner(this); +// Blockly.DropDownDiv.hideIfOwner(this); +// } + +// // remove resize listener +// window.removeEventListener("resize", this.resizeHandler); +// this.resetCrosshair = undefined; + +// // Destroy the selector div +// if (!this.selectorDiv_) return; +// goog.dom.removeNode(this.selectorDiv_); +// this.selectorDiv_ = undefined; +// } +// } + +// } \ No newline at end of file diff --git a/newblocks/fields/field_procedure.ts b/newblocks/fields/field_procedure.ts new file mode 100644 index 000000000000..cc268120c9ba --- /dev/null +++ b/newblocks/fields/field_procedure.ts @@ -0,0 +1,78 @@ +/// + +import * as Blockly from "blockly"; + +export class FieldProcedure extends Blockly.FieldDropdown { + + constructor(funcname: string, opt_validator?: Blockly.FieldValidator) { + super([["Temp", "Temp"]], opt_validator); + + this.setValue(funcname || ''); + } + + getOptions() { + return this.generateOptions(); + }; + + init() { + if (this.fieldGroup_) { + // Dropdown has already been initialized once. + return; + } + super.init.call(this); + }; + + setSourceBlock(block: Blockly.Block) { + (goog as any).asserts.assert(!(block as any).isShadow(), + 'Procedure fields are not allowed to exist on shadow blocks.'); + super.setSourceBlock.call(this, block); + }; + + /** + * Return a sorted list of variable names for procedure dropdown menus. + * Include a special option at the end for creating a new function name. + * @return {!Array.} Array of procedure names. + * @this {pxtblockly.FieldProcedure} + */ + public generateOptions() { + let functionList: string[] = []; + if (this.sourceBlock_ && this.sourceBlock_.workspace) { + let blocks = this.sourceBlock_.workspace.getAllBlocks(false); + // Iterate through every block and check the name. + for (let i = 0; i < blocks.length; i++) { + if ((blocks[i] as any).getProcedureDef) { + let procName = (blocks[i] as any).getProcedureDef(); + functionList.push(procName[0]); + } + } + } + // Ensure that the currently selected variable is an option. + let name = this.getValue(); + if (name && functionList.indexOf(name) == -1) { + functionList.push(name); + } + // FIXME (riknoll) + // functionList.sort(goog.string.caseInsensitiveCompare); + + + if (!functionList.length) { + // Add temporary list item so the dropdown doesn't break + functionList.push("Temp"); + } + + // Variables are not language-specific, use the name as both the user-facing + // text and the internal representation. + let options: [string, string][] = []; + for (let i = 0; i < functionList.length; i++) { + options[i] = [functionList[i], functionList[i]]; + } + return options; + } + + onItemSelected(menu: any, menuItem: any) { + let itemText = menuItem.getValue(); + if (itemText !== null) { + this.setValue(itemText); + } + } +} \ No newline at end of file diff --git a/newblocks/fields/field_protractor.ts b/newblocks/fields/field_protractor.ts new file mode 100644 index 000000000000..9cd969992463 --- /dev/null +++ b/newblocks/fields/field_protractor.ts @@ -0,0 +1,80 @@ +// /// + +// import * as Blockly from "blockly"; + +// namespace pxtblockly { +// export interface FieldProtractorOptions extends Blockly.FieldCustomOptions { +// } + +// export class FieldProtractor extends Blockly.FieldSlider implements Blockly.FieldCustom { +// public isFieldCustom_ = true; + +// private params: any; + +// private circleSVG: SVGElement; +// private circleBar: SVGCircleElement; +// private reporter: SVGTextElement; + +// /** +// * Class for a color wheel field. +// * @param {number|string} value The initial content of the field. +// * @param {Function=} opt_validator An optional function that is called +// * to validate any constraints on what the user entered. Takes the new +// * text as an argument and returns either the accepted text, a replacement +// * text, or null to abort the change. +// * @extends {Blockly.FieldNumber} +// * @constructor +// */ +// constructor(value_: any, params: FieldProtractorOptions, opt_validator?: Function) { +// super(String(value_), '0', '180', '1', '15', lf("Angle"), opt_validator); +// this.params = params; +// } + +// createLabelDom_(labelText: string) { +// const labelContainer = document.createElement('div'); +// this.circleSVG = document.createElementNS("http://www.w3.org/2000/svg", "svg") as SVGGElement; +// pxsim.svg.hydrate(this.circleSVG, { +// viewBox: "0 0 200 100", +// width: "170" +// }); + +// labelContainer.appendChild(this.circleSVG); + +// const outerCircle = pxsim.svg.child(this.circleSVG, "circle", { +// 'stroke-dasharray': '565.48', 'stroke-dashoffset': '0', +// 'cx': 100, 'cy': 100, 'r': '90', 'style': `fill:transparent; transition: stroke-dashoffset 0.1s linear;`, +// 'stroke': '#a8aaa8', 'stroke-width': '1rem' +// }) as SVGCircleElement; +// this.circleBar = pxsim.svg.child(this.circleSVG, "circle", { +// 'stroke-dasharray': '565.48', 'stroke-dashoffset': '0', +// 'cx': 100, 'cy': 100, 'r': '90', 'style': `fill:transparent; transition: stroke-dashoffset 0.1s linear;`, +// 'stroke': '#f12a21', 'stroke-width': '1rem' +// }) as SVGCircleElement; + +// this.reporter = pxsim.svg.child(this.circleSVG, "text", { +// 'x': 100, 'y': 80, +// 'text-anchor': 'middle', 'dominant-baseline': 'middle', +// 'style': 'font-size: 50px', +// 'class': 'sim-text inverted number' +// }) as SVGTextElement; + +// // labelContainer.setAttribute('class', 'blocklyFieldSliderLabel'); +// const readout = document.createElement('span'); +// readout.setAttribute('class', 'blocklyFieldSliderReadout'); +// return [labelContainer, readout]; +// }; + +// setReadout_(readout: Element, value: string) { +// this.updateAngle(parseFloat(value)); +// // Update reporter +// this.reporter.textContent = `${value}°`; +// } + +// private updateAngle(angle: number) { +// angle = Math.max(0, Math.min(180, angle)); +// const radius = 90; +// const pct = (180 - angle) / 180 * Math.PI * radius; +// this.circleBar.setAttribute('stroke-dashoffset', `${pct}`); +// } +// } +// } \ No newline at end of file diff --git a/newblocks/fields/field_sound_effect.ts b/newblocks/fields/field_sound_effect.ts new file mode 100644 index 000000000000..8e40e1783618 --- /dev/null +++ b/newblocks/fields/field_sound_effect.ts @@ -0,0 +1,440 @@ +// /// + +// import * as Blockly from "blockly"; + +// namespace pxtblockly { +// import svg = pxt.svgUtil; + +// export interface FieldSoundEffectParams extends Blockly.FieldCustomOptions { +// durationInputName: string; +// startFrequencyInputName: string; +// endFrequencyInputName: string; +// startVolumeInputName: string; +// endVolumeInputName: string; +// waveFieldName: string; +// interpolationFieldName: string; +// effectFieldName: string; +// useMixerSynthesizer: any; +// } + +// const MUSIC_ICON_WIDTH = 20; +// const TOTAL_WIDTH = 160; +// const TOTAL_HEIGHT = 40; +// const X_PADDING = 5; +// const Y_PADDING = 4; +// const PREVIEW_WIDTH = TOTAL_WIDTH - X_PADDING * 5 - MUSIC_ICON_WIDTH; + +// export class FieldSoundEffect extends FieldBase { +// protected mostRecentValue: pxt.assets.Sound; +// protected drawnSound: pxt.assets.Sound; +// protected workspace: Blockly.Workspace; +// protected registeredChangeListener = false; + +// protected onInit(): void { +// if (!this.options) this.options = {} as any; +// if (!this.options.durationInputName) this.options.durationInputName = "duration"; +// if (!this.options.startFrequencyInputName) this.options.startFrequencyInputName = "startFrequency"; +// if (!this.options.endFrequencyInputName) this.options.endFrequencyInputName = "endFrequency"; +// if (!this.options.startVolumeInputName) this.options.startVolumeInputName = "startVolume"; +// if (!this.options.endVolumeInputName) this.options.endVolumeInputName = "endVolume"; +// if (!this.options.waveFieldName) this.options.waveFieldName = "waveShape"; +// if (!this.options.interpolationFieldName) this.options.interpolationFieldName = "interpolation"; +// if (!this.options.effectFieldName) this.options.effectFieldName = "effect"; +// if (!this.options.useMixerSynthesizer) this.options.useMixerSynthesizer = false; + +// this.redrawPreview(); + +// if (this.sourceBlock_.workspace) { +// this.workspace = this.sourceBlock_.workspace; +// if (!this.sourceBlock_.isShadow() && !this.sourceBlock_.isInsertionMarker()) { +// this.registeredChangeListener = true; +// this.workspace.addChangeListener(this.onWorkspaceChange); +// } +// } +// } + +// protected onDispose(): void { +// if (this.workspace && this.registeredChangeListener) { +// this.workspace.removeChangeListener(this.onWorkspaceChange); +// this.registeredChangeListener = false; +// } +// } + +// protected onValueChanged(newValue: string): string { +// return newValue; +// } + +// redrawPreview() { +// if (!this.fieldGroup_) return; + +// if (this.drawnSound) { +// const current = this.readCurrentSound(); +// if (current.startFrequency === this.drawnSound.startFrequency && +// current.endFrequency === this.drawnSound.endFrequency && +// current.startVolume === this.drawnSound.startVolume && +// current.endVolume === this.drawnSound.endVolume && +// current.wave === this.drawnSound.wave && +// current.interpolation === this.drawnSound.interpolation +// ) { +// return; +// } +// } + +// pxsim.U.clear(this.fieldGroup_); +// const bg = new svg.Rect() +// .at(X_PADDING, Y_PADDING) +// .size(TOTAL_WIDTH, TOTAL_HEIGHT) +// .setClass("blocklySpriteField") +// .stroke("#fff", 1) +// .fill("#dedede") +// .corner(TOTAL_HEIGHT / 2); + +// const clipPathId = "preview-clip-" + pxt.U.guidGen(); + +// const clip = new svg.ClipPath() +// .id(clipPathId) +// .clipPathUnits(false) + +// const clipRect = new svg.Rect() +// .size(PREVIEW_WIDTH, TOTAL_HEIGHT) +// .fill("#FFF") +// .at(0, 0); + +// clip.appendChild(clipRect); + +// this.drawnSound = this.readCurrentSound(); +// const path = new svg.Path() +// .stroke("grey", 2) +// .fill("none") +// .setD(pxt.assets.renderSoundPath(this.drawnSound, TOTAL_WIDTH - X_PADDING * 4 - MUSIC_ICON_WIDTH, TOTAL_HEIGHT - Y_PADDING * 2)) +// .clipPath("url('#" + clipPathId + "')") + + +// const g = new svg.Group() +// .translate(MUSIC_ICON_WIDTH + X_PADDING * 3, Y_PADDING + 3); + +// g.appendChild(clip); +// g.appendChild(path); + +// const musicIcon = new svg.Text("\uf001") +// .appendClass("melody-editor-field-icon") +// .setAttribute("alignment-baseline", "middle") +// .anchor("middle") +// .at(X_PADDING * 2 + MUSIC_ICON_WIDTH / 2, TOTAL_HEIGHT / 2 + 4); + +// this.fieldGroup_.appendChild(bg.el); +// this.fieldGroup_.appendChild(musicIcon.el); +// this.fieldGroup_.appendChild(g.el); +// } + +// showEditor_() { +// const initialSound = this.readCurrentSound(); +// Blockly.Events.disable(); + +// let bbox: Blockly.utils.Rect; + +// // This is due to the changes in https://github.com/microsoft/pxt-blockly/pull/289 +// // which caused the widgetdiv to jump around if any fields underneath changed size +// let widgetOwner = { +// getScaledBBox: () => bbox +// } + +// Blockly.WidgetDiv.show(widgetOwner, this.sourceBlock_.RTL, () => { +// if (document.activeElement && document.activeElement.tagName === "INPUT") (document.activeElement as HTMLInputElement).blur(); + +// fv.hide(); + +// widgetDiv.classList.remove("sound-effect-editor-widget"); +// widgetDiv.style.transform = ""; +// widgetDiv.style.position = ""; +// widgetDiv.style.left = ""; +// widgetDiv.style.top = ""; +// widgetDiv.style.width = ""; +// widgetDiv.style.height = ""; +// widgetDiv.style.opacity = ""; +// widgetDiv.style.transition = ""; +// widgetDiv.style.alignItems = ""; + +// Blockly.Events.enable(); +// Blockly.Events.setGroup(true); +// this.fireNumberInputUpdate(this.options.durationInputName, initialSound.duration); +// this.fireNumberInputUpdate(this.options.startFrequencyInputName, initialSound.startFrequency); +// this.fireNumberInputUpdate(this.options.endFrequencyInputName, initialSound.endFrequency); +// this.fireNumberInputUpdate(this.options.startVolumeInputName, initialSound.startVolume); +// this.fireNumberInputUpdate(this.options.endVolumeInputName, initialSound.endVolume); +// this.fireFieldDropdownUpdate(this.options.waveFieldName, waveformMapping[initialSound.wave]); +// this.fireFieldDropdownUpdate(this.options.interpolationFieldName, interpolationMapping[initialSound.interpolation]); +// this.fireFieldDropdownUpdate(this.options.effectFieldName, effectMapping[initialSound.effect]); +// Blockly.Events.setGroup(false); + +// if (this.mostRecentValue) this.setBlockData(JSON.stringify(this.mostRecentValue)) +// }) + +// const widgetDiv = Blockly.WidgetDiv.DIV as HTMLDivElement; +// const opts = { +// onClose: () => { +// fv.hide(); +// Blockly.WidgetDiv.hideIfOwner(widgetOwner); +// }, +// onSoundChange: (newSound: pxt.assets.Sound) => { +// this.mostRecentValue = newSound; +// this.updateSiblingBlocks(newSound); +// this.redrawPreview(); +// }, +// initialSound: initialSound, +// useMixerSynthesizer: isTrue(this.options.useMixerSynthesizer) +// } + +// const fv = pxt.react.getFieldEditorView("soundeffect-editor", initialSound, opts, widgetDiv); + +// const block = this.sourceBlock_ as Blockly.BlockSvg; +// const bounds = block.getBoundingRectangle(); +// const coord = workspaceToScreenCoordinates(block.workspace as Blockly.WorkspaceSvg, +// new Blockly.utils.Coordinate(bounds.right, bounds.top)); + +// const animationDistance = 20; + +// const left = coord.x + 20; +// const top = coord.y - animationDistance; +// widgetDiv.style.opacity = "0"; +// widgetDiv.classList.add("sound-effect-editor-widget"); +// widgetDiv.style.position = "absolute"; +// widgetDiv.style.left = left + "px"; +// widgetDiv.style.top = top + "px"; +// widgetDiv.style.width = "30rem"; +// widgetDiv.style.height = "40rem"; +// widgetDiv.style.display = "flex"; +// widgetDiv.style.alignItems = "center"; +// widgetDiv.style.transition = "transform 0.25s ease 0s, opacity 0.25s ease 0s"; +// widgetDiv.style.borderRadius = ""; + +// fv.onHide(() => { +// // do nothing +// }); + +// fv.show(); + +// const divBounds = widgetDiv.getBoundingClientRect(); +// const injectDivBounds = block.workspace.getInjectionDiv().getBoundingClientRect(); + +// if (divBounds.height > injectDivBounds.height) { +// widgetDiv.style.height = ""; +// widgetDiv.style.top = `calc(1rem - ${animationDistance}px)`; +// widgetDiv.style.bottom = `calc(1rem + ${animationDistance}px)`; +// } +// else { +// if (divBounds.bottom > injectDivBounds.bottom || divBounds.top < injectDivBounds.top) { +// // This editor is pretty tall, so just center vertically on the inject div +// widgetDiv.style.top = (injectDivBounds.top + (injectDivBounds.height / 2) - (divBounds.height / 2)) - animationDistance + "px"; +// } +// } + +// const toolboxWidth = block.workspace.getToolbox().getWidth(); + +// if (divBounds.width > injectDivBounds.width - toolboxWidth) { +// widgetDiv.style.width = ""; +// widgetDiv.style.left = "1rem"; +// widgetDiv.style.right = "1rem"; +// } +// else { +// // Check to see if we are bleeding off the right side of the canvas +// if (divBounds.left + divBounds.width >= injectDivBounds.right) { +// // If so, try and place to the left of the block instead of the right +// const blockLeft = workspaceToScreenCoordinates(block.workspace as Blockly.WorkspaceSvg, +// new Blockly.utils.Coordinate(bounds.left, bounds.top)); + +// const workspaceLeft = injectDivBounds.left + toolboxWidth; + +// if (blockLeft.x - divBounds.width - 20 > workspaceLeft) { +// widgetDiv.style.left = (blockLeft.x - divBounds.width - 20) + "px" +// } +// else { +// // As a last resort, just center on the inject div +// widgetDiv.style.left = (workspaceLeft + ((injectDivBounds.width - toolboxWidth) / 2) - divBounds.width / 2) + "px"; +// } +// } +// } + +// const finalDimensions = widgetDiv.getBoundingClientRect(); +// bbox = new Blockly.utils.Rect(finalDimensions.top, finalDimensions.bottom, finalDimensions.left, finalDimensions.right); + +// requestAnimationFrame(() => { +// widgetDiv.style.opacity = "1"; +// widgetDiv.style.transform = `translateY(${animationDistance}px)`; +// }) +// } + +// render_() { +// super.render_(); + +// this.size_.height = TOTAL_HEIGHT + Y_PADDING * 2; +// this.size_.width = TOTAL_WIDTH + X_PADDING; +// } + +// protected updateSiblingBlocks(sound: pxt.assets.Sound) { +// this.setNumberInputValue(this.options.durationInputName, sound.duration); +// this.setNumberInputValue(this.options.startFrequencyInputName, sound.startFrequency); +// this.setNumberInputValue(this.options.endFrequencyInputName, sound.endFrequency); +// this.setNumberInputValue(this.options.startVolumeInputName, sound.startVolume); +// this.setNumberInputValue(this.options.endVolumeInputName, sound.endVolume); +// this.setFieldDropdownValue(this.options.waveFieldName, waveformMapping[sound.wave]); +// this.setFieldDropdownValue(this.options.interpolationFieldName, interpolationMapping[sound.interpolation]); +// this.setFieldDropdownValue(this.options.effectFieldName, effectMapping[sound.effect]); +// } + +// protected setNumberInputValue(name: string, value: number) { +// const block = this.getSiblingBlock(name) || this.getSiblingBlock(name, true); +// if (!block) return; + +// if (block.type === "math_number" || block.type === "math_integer" || block.type === "math_whole_number") { +// block.setFieldValue(Math.round(value), "NUM"); +// } +// else if (block.type === "math_number_minmax") { +// block.setFieldValue(Math.round(value), "SLIDER"); +// } +// } + +// protected getNumberInputValue(name: string, defaultValue: number) { +// const block = this.getSiblingBlock(name) || this.getSiblingBlock(name, true); +// if (!block) return defaultValue; + +// if (block.type === "math_number" || block.type === "math_integer" || block.type === "math_whole_number") { +// return parseInt(block.getFieldValue("NUM") + ""); +// } +// else if (block.type === "math_number_minmax") { +// return parseInt(block.getFieldValue("SLIDER") + ""); +// } +// return defaultValue; +// } + +// protected fireNumberInputUpdate(name: string, oldValue: number) { +// const block = this.getSiblingBlock(name) || this.getSiblingBlock(name, true); + +// if (!block) return; + +// let fieldName: string +// if (block.type === "math_number" || block.type === "math_integer" || block.type === "math_whole_number") { +// fieldName = "NUM"; +// } +// else if (block.type === "math_number_minmax") { +// fieldName = "SLIDER"; +// } + +// if (!fieldName) return; + +// Blockly.Events.fire(new Blockly.Events.Change(block, "field", fieldName, oldValue, this.getNumberInputValue(name, oldValue))); +// } + +// protected setFieldDropdownValue(name: string, value: string) { +// const field = this.getSiblingField(name) || this.getSiblingField(name, true); +// if (!field) return; +// field.setValue(value); +// } + +// protected getFieldDropdownValue(name: string) { +// const field = this.getSiblingField(name) || this.getSiblingField(name, true); +// if (!field) return undefined; +// return field.getValue() as string; +// } + +// protected fireFieldDropdownUpdate(name: string, oldValue: string) { +// const field = this.getSiblingField(name) || this.getSiblingField(name, true); + +// if (!field) return; + +// Blockly.Events.fire(new Blockly.Events.Change(field.sourceBlock_, "field", field.name, oldValue, this.getFieldDropdownValue(name))); +// } + +// protected readCurrentSound(): pxt.assets.Sound { +// const savedSound = this.readBlockDataSound(); +// return { +// duration: this.getNumberInputValue(this.options.durationInputName, savedSound.duration), +// startFrequency: this.getNumberInputValue(this.options.startFrequencyInputName, savedSound.startFrequency), +// endFrequency: this.getNumberInputValue(this.options.endFrequencyInputName, savedSound.endFrequency), +// startVolume: this.getNumberInputValue(this.options.startVolumeInputName, savedSound.startVolume), +// endVolume: this.getNumberInputValue(this.options.endVolumeInputName, savedSound.endVolume), +// wave: reverseLookup(waveformMapping, this.getFieldDropdownValue(this.options.waveFieldName)) as any || savedSound.wave, +// interpolation: reverseLookup(interpolationMapping, this.getFieldDropdownValue(this.options.interpolationFieldName)) as any || savedSound.interpolation, +// effect: reverseLookup(effectMapping, this.getFieldDropdownValue(this.options.effectFieldName)) as any || savedSound.effect, +// } +// } + +// // This stores the values of the fields in case a block (e.g. a variable) is placed in one +// // of the inputs. +// protected readBlockDataSound(): pxt.assets.Sound { +// const data = this.getBlockData(); +// let sound: pxt.assets.Sound; + +// try { +// sound = JSON.parse(data); +// } +// catch (e) { +// sound = { +// duration: 1000, +// startFrequency: 100, +// endFrequency: 4800, +// startVolume: 100, +// endVolume: 0, +// wave: "sine", +// interpolation: "linear", +// effect: "none" +// } +// } + +// return sound; +// } + +// protected onWorkspaceChange = (ev: Blockly.Events.BlockChange) => { +// if (ev.type !== Blockly.Events.CHANGE) return; + +// const block = this.sourceBlock_.workspace.getBlockById(ev.blockId); +// if (!block || block !== this.sourceBlock_ && block.parentBlock_ !== this.sourceBlock_) return; + +// this.redrawPreview(); +// } +// } + +// const waveformMapping: {[index: string]: string} = { +// "sine": "WaveShape.Sine", +// "square": "WaveShape.Square", +// "sawtooth": "WaveShape.Sawtooth", +// "triangle": "WaveShape.Triangle", +// "noise": "WaveShape.Noise", +// }; + +// const effectMapping: {[index: string]: string} = { +// "none": "SoundExpressionEffect.None", +// "vibrato": "SoundExpressionEffect.Vibrato", +// "tremolo": "SoundExpressionEffect.Tremolo", +// "warble": "SoundExpressionEffect.Warble", +// }; + +// const interpolationMapping: {[index: string]: string} = { +// "linear": "InterpolationCurve.Linear", +// "curve": "InterpolationCurve.Curve", +// "logarithmic": "InterpolationCurve.Logarithmic", +// }; + +// function reverseLookup(map: {[index: string]: string}, value: string) { +// return Object.keys(map).find(k => map[k] === value); +// } + +// function isTrue(value: any) { +// if (!value) return false; + +// if (typeof value === "string") { +// switch (value.toLowerCase().trim()) { +// case "1": +// case "yes": +// case "y": +// case "on": +// case "true": +// return true; +// default: +// return false; +// } +// } + +// return !!value; +// } +// } \ No newline at end of file diff --git a/newblocks/fields/field_speed.ts b/newblocks/fields/field_speed.ts new file mode 100644 index 000000000000..079ecc264d9b --- /dev/null +++ b/newblocks/fields/field_speed.ts @@ -0,0 +1,100 @@ +// /// + +// import * as Blockly from "blockly"; + +// namespace pxtblockly { +// export interface FieldSpeedOptions extends Blockly.FieldCustomOptions { +// min?: string; +// max?: string; +// format?: string; +// label?: string; +// } + +// export class FieldSpeed extends Blockly.FieldSlider implements Blockly.FieldCustom { +// public isFieldCustom_ = true; + +// private params: FieldSpeedOptions; + +// private speedSVG: SVGElement; +// private circleBar: SVGCircleElement; +// private reporter: SVGTextElement; + +// /** +// * Class for a color wheel field. +// * @param {number|string} value The initial content of the field. +// * @param {Function=} opt_validator An optional function that is called +// * to validate any constraints on what the user entered. Takes the new +// * text as an argument and returns either the accepted text, a replacement +// * text, or null to abort the change. +// * @extends {Blockly.FieldNumber} +// * @constructor +// */ +// constructor(value_: any, params: FieldSpeedOptions, opt_validator?: Function) { +// super(String(value_), '-100', '100', '1', '10', 'Speed', opt_validator); +// this.params = params; +// if (this.params['min']) this.min_ = parseFloat(this.params.min); +// if (this.params['max']) this.max_ = parseFloat(this.params.max); +// if (this.params['label']) this.labelText_ = this.params.label; +// if (!this.params.format) this.params.format = "{0}%"; +// } + +// createLabelDom_(labelText: string) { +// const labelContainer = document.createElement('div'); +// this.speedSVG = document.createElementNS("http://www.w3.org/2000/svg", "svg") as SVGGElement; +// pxsim.svg.hydrate(this.speedSVG, { +// viewBox: "0 0 200 100", +// width: "170" +// }); + +// labelContainer.appendChild(this.speedSVG); + +// const outerCircle = pxsim.svg.child(this.speedSVG, "circle", { +// 'stroke-dasharray': '565.48', 'stroke-dashoffset': '0', +// 'cx': 100, 'cy': 100, 'r': '90', 'style': `fill:transparent; transition: stroke-dashoffset 0.1s linear;`, +// 'stroke': '#a8aaa8', 'stroke-width': '1rem' +// }) as SVGCircleElement; +// this.circleBar = pxsim.svg.child(this.speedSVG, "circle", { +// 'stroke-dasharray': '565.48', 'stroke-dashoffset': '0', +// 'cx': 100, 'cy': 100, 'r': '90', 'style': `fill:transparent; transition: stroke-dashoffset 0.1s linear;`, +// 'stroke': '#f12a21', 'stroke-width': '1rem' +// }) as SVGCircleElement; + +// this.reporter = pxsim.svg.child(this.speedSVG, "text", { +// 'x': 100, 'y': 80, +// 'text-anchor': 'middle', 'dominant-baseline': 'middle', +// 'style': `font-size: ${Math.max(14, 50 - 5 * (this.params.format.length - 4))}px`, +// 'class': 'sim-text inverted number' +// }) as SVGTextElement; + +// // labelContainer.setAttribute('class', 'blocklyFieldSliderLabel'); +// const readout = document.createElement('span'); +// readout.setAttribute('class', 'blocklyFieldSliderReadout'); +// // var label = document.createElement('span'); +// // label.setAttribute('class', 'blocklyFieldSliderLabelText'); +// // label.innerHTML = labelText; +// // labelContainer.appendChild(label); +// // labelContainer.appendChild(readout); +// return [labelContainer, readout]; +// }; + +// setReadout_(readout: Element, value: string) { +// this.updateSpeed(parseFloat(value)); +// // Update reporter +// this.reporter.textContent = ts.pxtc.U.rlf(this.params.format, value); +// } + +// private updateSpeed(speed: number) { +// let sign = this.sign(speed); +// speed = (Math.abs(speed) / 100 * 50) + 50; +// if (sign == -1) speed = 50 - speed; +// let c = Math.PI * (90 * 2); +// let pct = ((100 - speed) / 100) * c; +// this.circleBar.setAttribute('stroke-dashoffset', `${pct}`); +// } + +// // A re-implementation of Math.sign (since IE11 doesn't support it) +// private sign(num: number) { +// return num ? num < 0 ? -1 : 1 : 0; +// } +// } +// } \ No newline at end of file diff --git a/newblocks/fields/field_sprite.ts b/newblocks/fields/field_sprite.ts new file mode 100644 index 000000000000..9cd6ce0ae033 --- /dev/null +++ b/newblocks/fields/field_sprite.ts @@ -0,0 +1,153 @@ +// /// + +// import * as Blockly from "blockly"; + + +// namespace pxtblockly { +// export interface FieldSpriteEditorOptions { +// // Deprecated +// sizes: string; + +// // Index of initial color (defaults to 1) +// initColor: string; + +// initWidth: string; +// initHeight: string; + +// disableResize: string; + +// filter?: string; +// lightMode: boolean; +// } + +// interface ParsedSpriteEditorOptions { +// initColor: number; +// initWidth: number; +// initHeight: number; +// disableResize: boolean; +// filter?: string; +// lightMode: boolean; +// } + +// export class FieldSpriteEditor extends FieldAssetEditor { +// protected getAssetType(): pxt.AssetType { +// return pxt.AssetType.Image; +// } + +// protected createNewAsset(text?: string): pxt.Asset { +// const project = pxt.react.getTilemapProject(); +// if (text) { +// const asset = pxt.lookupProjectAssetByTSReference(text, project); + +// if (asset) return asset; +// } + +// if (this.getBlockData()) { +// return project.lookupAsset(pxt.AssetType.Image, this.getBlockData()); +// } + +// const bmp = text ? pxt.sprite.imageLiteralToBitmap(text) : new pxt.sprite.Bitmap(this.params.initWidth, this.params.initHeight); + +// if (!bmp) { +// this.isGreyBlock = true; +// this.valueText = text; +// return undefined; +// } + +// const data = bmp.data(); + +// const newAsset: pxt.ProjectImage = { +// internalID: -1, +// id: this.sourceBlock_.id, +// type: pxt.AssetType.Image, +// jresData: pxt.sprite.base64EncodeBitmap(data), +// meta: { +// }, +// bitmap: data +// }; + +// return newAsset; +// } + +// protected getValueText(): string { +// if (this.asset && !this.isTemporaryAsset()) { +// return pxt.getTSReferenceForAsset(this.asset); +// } +// return pxt.sprite.bitmapToImageLiteral(this.asset && pxt.sprite.Bitmap.fromData((this.asset as pxt.ProjectImage).bitmap), pxt.editor.FileType.TypeScript); +// } + +// protected parseFieldOptions(opts: FieldSpriteEditorOptions): ParsedSpriteEditorOptions { +// return parseFieldOptions(opts); +// } +// } + +// function parseFieldOptions(opts: FieldSpriteEditorOptions) { +// // NOTE: This implementation is duplicated in pxtcompiler/emitter/service.ts +// // TODO: Refactor to share implementation. +// const parsed: ParsedSpriteEditorOptions = { +// initColor: 1, +// initWidth: 16, +// initHeight: 16, +// disableResize: false, +// lightMode: false, +// }; + +// if (!opts) { +// return parsed; +// } + +// parsed.lightMode = opts.lightMode; + +// if (opts.sizes) { +// const pairs = opts.sizes.split(";"); +// const sizes: [number, number][] = []; +// for (let i = 0; i < pairs.length; i++) { +// const pair = pairs[i].split(","); +// if (pair.length !== 2) { +// continue; +// } + +// let width = parseInt(pair[0]); +// let height = parseInt(pair[1]); + +// if (isNaN(width) || isNaN(height)) { +// continue; +// } + +// const screenSize = pxt.appTarget.runtime && pxt.appTarget.runtime.screenSize; +// if (width < 0 && screenSize) +// width = screenSize.width; +// if (height < 0 && screenSize) +// height = screenSize.height; + +// sizes.push([width, height]); +// } +// if (sizes.length > 0) { +// parsed.initWidth = sizes[0][0]; +// parsed.initHeight = sizes[0][1]; +// } +// } + +// if (opts.filter) { +// parsed.filter = opts.filter; +// } + +// if (opts.disableResize) { +// parsed.disableResize = opts.disableResize.toLowerCase() === "true" || opts.disableResize === "1"; +// } + +// parsed.initColor = withDefault(opts.initColor, parsed.initColor); +// parsed.initWidth = withDefault(opts.initWidth, parsed.initWidth); +// parsed.initHeight = withDefault(opts.initHeight, parsed.initHeight); + +// return parsed; + +// function withDefault(raw: string, def: number) { +// const res = parseInt(raw); +// if (isNaN(res)) { +// return def; +// } +// return res; +// } +// } +// } diff --git a/newblocks/fields/field_styledlabel.ts b/newblocks/fields/field_styledlabel.ts new file mode 100644 index 000000000000..31c914eace36 --- /dev/null +++ b/newblocks/fields/field_styledlabel.ts @@ -0,0 +1,32 @@ +/// + +import * as Blockly from "blockly"; +import { FieldCustomOptions, FieldCustom } from "./field_utils"; + +export interface StyleOptions extends FieldCustomOptions { + bold: boolean; + italics: boolean; +} + +export class FieldStyledLabel extends Blockly.FieldLabel implements FieldCustom { + public isFieldCustom_ = true; + + constructor(value: string, options?: StyleOptions, opt_validator?: Function) { + super(value, getClass(options)); + } +} + +function getClass(options?: StyleOptions) { + if (options) { + if (options.bold && options.italics) { + return 'blocklyBoldItalicizedText' + } + else if (options.bold) { + return 'blocklyBoldText' + } + else if (options.italics) { + return 'blocklyItalicizedText' + } + } + return undefined; +} \ No newline at end of file diff --git a/newblocks/fields/field_textdropdown.ts b/newblocks/fields/field_textdropdown.ts new file mode 100644 index 000000000000..3c8023670497 --- /dev/null +++ b/newblocks/fields/field_textdropdown.ts @@ -0,0 +1,18 @@ +// /// + +// import * as Blockly from "blockly"; + +// namespace pxtblockly { + +// export interface FieldTextDropdownOptions extends Blockly.FieldCustomOptions { +// values: any; +// } + +// export class FieldTextDropdown extends Blockly.FieldTextDropdown implements Blockly.FieldCustom { +// public isFieldCustom_ = true; + +// constructor(text: string, options: FieldTextDropdownOptions, opt_validator?: Function) { +// super(text, options.values, opt_validator); +// } +// } +// } \ No newline at end of file diff --git a/newblocks/fields/field_textinput.ts b/newblocks/fields/field_textinput.ts new file mode 100644 index 000000000000..b7391ac3179a --- /dev/null +++ b/newblocks/fields/field_textinput.ts @@ -0,0 +1,10 @@ +import * as Blockly from "blockly"; +import { FieldCustom, FieldCustomOptions } from "./field_utils"; + +export class FieldTextInput extends Blockly.FieldTextInput implements FieldCustom { + public isFieldCustom_ = true; + + constructor(value: string, options: FieldCustomOptions, opt_validator?: Blockly.FieldValidator) { + super(value, opt_validator); + } +} \ No newline at end of file diff --git a/newblocks/fields/field_tilemap.ts b/newblocks/fields/field_tilemap.ts new file mode 100644 index 000000000000..f6600d3d649e --- /dev/null +++ b/newblocks/fields/field_tilemap.ts @@ -0,0 +1,162 @@ +/// + +import * as Blockly from "blockly"; +import { FieldAssetEditor } from "./field_asset"; + +export interface FieldTilemapOptions { + initWidth: string; + initHeight: string; + disableResize: string; + tileWidth: string | number; + + filter?: string; + lightMode: boolean; +} + +interface ParsedFieldTilemapOptions { + initWidth: number; + initHeight: number; + disableResize: boolean; + tileWidth: 8 | 16 | 32; + filter?: string; + lightMode: boolean; +} + +export class FieldTilemap extends FieldAssetEditor { + protected initText: string; + protected asset: pxt.ProjectTilemap; + + getInitText() { + return this.initText; + } + + getTileset() { + return (this.asset as pxt.ProjectTilemap)?.data.tileset; + } + + protected getAssetType(): pxt.AssetType { + return pxt.AssetType.Tilemap; + } + + protected createNewAsset(newText = ""): pxt.Asset { + if (newText) { + // backticks are escaped inside markdown content + newText = newText.replace(/`/g, "`"); + } + + const project = pxt.react.getTilemapProject(); + + const existing = pxt.lookupProjectAssetByTSReference(newText, project); + if (existing) return existing; + + const tilemap = pxt.sprite.decodeTilemap(newText, "typescript", project) || project.blankTilemap(this.params.tileWidth, this.params.initWidth, this.params.initHeight); + let newAsset: pxt.ProjectTilemap; + + // Ignore invalid bitmaps + if (checkTilemap(tilemap)) { + this.initText = newText; + this.isGreyBlock = false; + const [ name ] = project.createNewTilemapFromData(tilemap); + newAsset = project.getTilemap(name); + } + else if (newText.trim()) { + this.isGreyBlock = true; + this.valueText = newText; + } + + return newAsset; + } + + protected onEditorClose(newValue: pxt.ProjectTilemap) { + pxt.sprite.updateTilemapReferencesFromResult(pxt.react.getTilemapProject(), newValue); + } + + protected getValueText(): string { + if (this.isGreyBlock) return pxt.Util.htmlUnescape(this.valueText); + + if (this.asset) { + return pxt.getTSReferenceForAsset(this.asset); + } + + return this.getInitText(); + } + + protected parseFieldOptions(opts: FieldTilemapOptions): ParsedFieldTilemapOptions { + return parseFieldOptions(opts); + } +} + +function parseFieldOptions(opts: FieldTilemapOptions) { + const parsed: ParsedFieldTilemapOptions = { + initWidth: 16, + initHeight: 16, + disableResize: false, + tileWidth: 16, + lightMode: false + }; + + if (!opts) { + return parsed; + } + + parsed.lightMode = opts.lightMode; + + if (opts.filter) { + parsed.filter = opts.filter; + } + + if (opts.tileWidth) { + if (typeof opts.tileWidth === "number") { + switch (opts.tileWidth) { + case 8: + parsed.tileWidth = 8; + break; + case 16: + parsed.tileWidth = 16; + break; + case 32: + parsed.tileWidth = 32; + break; + } + } + else { + const tw = opts.tileWidth.trim().toLowerCase(); + switch (tw) { + case "8": + case "eight": + parsed.tileWidth = 8; + break; + case "16": + case "sixteen": + parsed.tileWidth = 16; + break; + case "32": + case "thirtytwo": + parsed.tileWidth = 32; + break; + } + } + } + + parsed.initWidth = withDefault(opts.initWidth, parsed.initWidth); + parsed.initHeight = withDefault(opts.initHeight, parsed.initHeight); + + return parsed; + + function withDefault(raw: string, def: number) { + const res = parseInt(raw); + if (isNaN(res)) { + return def; + } + return res; + } +} +function checkTilemap(tilemap: pxt.sprite.TilemapData) { + if (!tilemap || !tilemap.tilemap || !tilemap.tilemap.width || !tilemap.tilemap.height) return false; + + if (!tilemap.layers || tilemap.layers.width !== tilemap.tilemap.width || tilemap.layers.height !== tilemap.tilemap.height) return false; + + if (!tilemap.tileset) return false; + + return true; +} \ No newline at end of file diff --git a/newblocks/fields/field_tileset.ts b/newblocks/fields/field_tileset.ts new file mode 100644 index 000000000000..cf096834344d --- /dev/null +++ b/newblocks/fields/field_tileset.ts @@ -0,0 +1,265 @@ +// /// + +// import * as Blockly from "blockly"; + +// namespace pxtblockly { +// export interface ImageJSON { +// src: string; +// alt: string; +// width: number; +// height: number; +// } + +// export type TilesetDropdownOption = [ImageJSON, string, pxt.Tile]; + +// const PREVIEW_SIDE_LENGTH = 32; + +// export class FieldTileset extends FieldImages implements Blockly.FieldCustom { +// // private member of FieldDropdown +// protected selectedOption_: TilesetDropdownOption; + +// protected static referencedTiles: TilesetDropdownOption[]; +// protected static cachedRevision: number; +// protected static cachedWorkspaceId: string; + +// protected static getReferencedTiles(workspace: Blockly.Workspace) { +// const project = pxt.react.getTilemapProject(); + +// if (project.revision() !== FieldTileset.cachedRevision || workspace.id != FieldTileset.cachedWorkspaceId) { +// FieldTileset.cachedRevision = project.revision(); +// FieldTileset.cachedWorkspaceId = workspace.id; +// const references = getAllReferencedTiles(workspace); + +// const supportedTileWidths = [16, 8, 32]; + +// for (const width of supportedTileWidths) { +// const projectTiles = project.getProjectTiles(width, width === 16); +// if (!projectTiles) continue; + +// for (const tile of projectTiles.tiles) { +// if (!references.find(t => t.id === tile.id)) { +// references.push(tile); +// } +// } +// } + + +// let weights: pxt.Map = {}; +// references.sort((a, b) => { +// if (a.id === b.id) return 0; + +// if (a.bitmap.width !== b.bitmap.width) { +// return a.bitmap.width - b.bitmap.width +// } + +// if (a.isProjectTile !== b.isProjectTile) { +// if (a.isProjectTile) return -1 +// else return 1; +// } + +// return (weights[a.id] || (weights[a.id] = tileWeight(a.id))) - +// (weights[b.id] || (weights[b.id] = tileWeight(b.id))) +// }); + +// const getTileImage = (t: pxt.Tile) => tileWeight(t.id) <= 2 ? +// mkTransparentTileImage(t.bitmap.width) : +// bitmapToImageURI(pxt.sprite.Bitmap.fromData(t.bitmap), PREVIEW_SIDE_LENGTH, false); + +// FieldTileset.referencedTiles = references.map(tile => [{ +// src: getTileImage(tile), +// width: PREVIEW_SIDE_LENGTH, +// height: PREVIEW_SIDE_LENGTH, +// alt: displayName(tile) +// }, tile.id, tile]) +// } +// return FieldTileset.referencedTiles; +// } + +// public isFieldCustom_ = true; +// protected selected: pxt.Tile; +// protected blocksInfo: pxtc.BlocksInfo; +// protected transparent: TilesetDropdownOption; + +// constructor(text: string, options: FieldImageDropdownOptions, validator?: Function) { +// super(text, options, validator); +// this.blocksInfo = options.blocksInfo; +// } + +// initView() { +// super.initView(); +// if (this.sourceBlock_ && this.sourceBlock_.isInFlyout) { +// this.setValue(this.getOptions()[0][1]); +// } +// } + +// getValue() { +// if (this.selectedOption_) { +// let tile = this.selectedOption_[2]; +// tile = pxt.react.getTilemapProject().lookupAsset(tile.type, tile.id); + +// if (!tile) { +// // This shouldn't happen +// return super.getValue(); +// } + +// return pxt.getTSReferenceForAsset(tile); +// } +// const v = super.getValue(); + +// // If the user decompiled from JavaScript, then they might have passed an image literal +// // instead of the qualified name of a tile. The decompiler strips out the "img" part +// // so we need to add it back +// if (typeof v === "string" && v.indexOf(".") === -1 && v.indexOf(`\``) === -1) { +// return `img\`${v}\`` +// } +// return v; +// } + +// getText() { +// const v = this.getValue(); + +// if (typeof v === "string" && v.indexOf("`") !== -1) { +// return v; +// } +// return super.getText(); +// } + +// render_() { +// if (this.value_ && this.selectedOption_) { +// if (this.selectedOption_[1] !== this.value_) { +// const tile = pxt.react.getTilemapProject().resolveTile(this.value_); +// FieldTileset.cachedRevision = -1; + +// if (tile) { +// this.selectedOption_ = [{ +// src: bitmapToImageURI(pxt.sprite.Bitmap.fromData(tile.bitmap), PREVIEW_SIDE_LENGTH, false), +// width: PREVIEW_SIDE_LENGTH, +// height: PREVIEW_SIDE_LENGTH, +// alt: displayName(tile) +// }, this.value_, tile] +// } +// } + +// } +// super.render_(); +// } + +// doValueUpdate_(newValue: string) { +// super.doValueUpdate_(newValue); +// const options: TilesetDropdownOption[] = this.getOptions(true); + +// // This text can be one of four things: +// // 1. The JavaScript expression (assets.tile`name`) +// // 2. The tile id (qualified name) +// // 3. The tile display name +// // 4. Something invalid (like an image literal or undefined) + +// if (newValue) { +// // If it's an expression, pull out the id +// const match = pxt.parseAssetTSReference(newValue); +// if (match) { +// newValue = match.name; +// } + +// newValue = newValue.trim(); + +// for (const option of options) { +// if (newValue === option[2].id || newValue === option[2].meta.displayName || newValue === pxt.getShortIDForAsset(option[2])) { +// this.selectedOption_ = option; +// this.value_ = this.getValue(); +// this.updateAssetListener(); +// return; +// } +// } + +// this.selectedOption_ = null; +// this.updateAssetListener(); +// } +// } + +// getOptions(opt_useCache?: boolean): any[] { +// if (typeof this.menuGenerator_ !== 'function') { +// this.transparent = constructTransparentTile(); +// return [this.transparent]; +// } + +// return this.menuGenerator_.call(this); +// } + +// menuGenerator_ = () => { +// if (this.sourceBlock_?.workspace && needsTilemapUpgrade(this.sourceBlock_?.workspace)) { +// return [constructTransparentTile()] +// } +// return FieldTileset.getReferencedTiles(this.sourceBlock_.workspace); +// } + +// dispose() { +// super.dispose(); +// pxt.react.getTilemapProject().removeChangeListener(pxt.AssetType.Tile, this.assetChangeListener); +// } + +// protected updateAssetListener() { +// const project = pxt.react.getTilemapProject(); +// project.removeChangeListener(pxt.AssetType.Tile, this.assetChangeListener); +// if (this.selectedOption_) { +// project.addChangeListener(this.selectedOption_[2], this.assetChangeListener); +// } +// } + +// protected assetChangeListener = () => { +// this.doValueUpdate_(this.getValue()); +// this.forceRerender(); +// } +// } + +// function constructTransparentTile(): TilesetDropdownOption { +// const tile = pxt.react.getTilemapProject().getTransparency(16); +// return [{ +// src: mkTransparentTileImage(16), +// width: PREVIEW_SIDE_LENGTH, +// height: PREVIEW_SIDE_LENGTH, +// alt: pxt.U.lf("transparency") +// }, tile.id, tile]; +// } + +// function mkTransparentTileImage(sideLength: number) { +// const canvas = document.createElement("canvas"); +// const context = canvas.getContext("2d"); +// canvas.width = sideLength; +// canvas.height = sideLength; + +// context.fillStyle = "#aeaeae"; +// context.fillRect(0, 0, sideLength, sideLength); + +// context.fillStyle = "#dedede"; + +// for (let x = 0; x < sideLength; x += 4) { +// for (let y = 0; y < sideLength; y += 4) { +// if (((x + y) >> 2) & 1) context.fillRect(x, y, 4, 4); +// } +// } + +// return canvas.toDataURL(); +// } + +// function tileWeight(id: string) { +// switch (id) { +// case "myTiles.transparency16": +// return 1; +// case "myTiles.transparency8": +// case "myTiles.transparency32": +// return 2; +// default: +// if (id.startsWith("myTiles.tile")) { +// const num = parseInt(id.slice(12)); + +// if (!Number.isNaN(num)) return num + 2; +// } +// return 9999999999; +// } +// } + +// function displayName(tile: pxt.Tile) { +// return tile.meta.displayName || pxt.getShortIDForAsset(tile); +// } +// } diff --git a/newblocks/fields/field_toggle.ts b/newblocks/fields/field_toggle.ts new file mode 100644 index 000000000000..e02174a20ae8 --- /dev/null +++ b/newblocks/fields/field_toggle.ts @@ -0,0 +1,296 @@ +// /// + +// import * as Blockly from "blockly"; +// import { FieldCustom, FieldCustomOptions } from "./field_utils"; +// import { provider } from "../constants"; + +// export class FieldToggle extends Blockly.FieldNumber implements FieldCustom { +// public isFieldCustom_ = true; + +// private params: any; + +// private state_: boolean; +// private checkElement_: SVGElement; + +// private toggleThumb_: SVGElement; + +// public CURSOR = 'pointer'; + +// private type_: string; + +// constructor(state: string, params: FieldCustomOptions, opt_validator?: Blockly.FieldValidator) { +// super(state, undefined, undefined, undefined, opt_validator); +// this.params = params; +// this.setValue(state); +// this.addArgType('toggle'); +// this.type_ = params.type; +// } + +// initView() { +// if (!this.fieldGroup_) { +// return; +// } +// // Add an attribute to cassify the type of field. +// if ((this as any).getArgTypes() !== null) { +// if (this.sourceBlock_.isShadow()) { +// (this.sourceBlock_ as any).svgGroup_.setAttribute('data-argument-type', +// (this as any).getArgTypes()); +// } else { +// // Fields without a shadow wrapper, like square dropdowns. +// this.fieldGroup_.setAttribute('data-argument-type', (this as any).getArgTypes()); +// } +// } +// // If not in a shadow block, and has more than one input, draw a box. +// if (!this.sourceBlock_.isShadow() +// && (this.sourceBlock_.inputList && this.sourceBlock_.inputList.length > 1)) { +// this.borderRect_ = Blockly.utils.dom.createSvgElement('rect', { +// 'rx': (Blockly as any).BlockSvg.CORNER_RADIUS, +// 'ry': (Blockly as any).BlockSvg.CORNER_RADIUS, +// 'x': 0, +// 'y': 0, +// 'width': this.size_.width, +// 'height': this.size_.height, +// 'fill': (this.sourceBlock_ as Blockly.BlockSvg).getColour(), +// 'stroke': (this.sourceBlock_ as Blockly.BlockSvg).getColourTertiary() +// }, null) as SVGRectElement; +// this.fieldGroup_.insertBefore(this.borderRect_, this.textElement_); +// } +// // Adjust X to be flipped for RTL. Position is relative to horizontal start of source block. +// const size = this.getSize(); +// this.checkElement_ = Blockly.utils.dom.createSvgElement('g', +// { +// 'class': `blocklyToggle ${this.state_ ? 'blocklyToggleOn' : 'blocklyToggleOff'}`, +// 'transform': `translate(8, ${size.height / 2})`, +// }, this.fieldGroup_); +// switch (this.getOutputShape()) { +// case provider.SHAPES.HEXAGONAL: +// this.toggleThumb_ = Blockly.utils.dom.createSvgElement('polygon', +// { +// 'class': 'blocklyToggleRect', +// 'points': '-7,-14 -21,0 -7,14 7,14 21,0 7,-14', +// 'cursor': 'pointer' +// }, +// this.checkElement_); +// break; +// case provider.SHAPES.ROUND: +// this.toggleThumb_ = Blockly.utils.dom.createSvgElement('rect', +// { +// 'class': 'blocklyToggleCircle', +// 'x': -6, 'y': -14, 'height': 28, +// 'width': 28, 'rx': 14, 'ry': 14, +// 'cursor': 'pointer' +// }, +// this.checkElement_); +// break; +// case provider.SHAPES.SQUARE: +// this.toggleThumb_ = Blockly.utils.dom.createSvgElement('rect', +// { +// 'class': 'blocklyToggleRect', +// 'x': -6, 'y': -14, 'height': 28, +// 'width': 28, 'rx': 3, 'ry': 3, +// 'cursor': 'pointer' +// }, +// this.checkElement_); +// break; +// } + +// let fieldX = (this.sourceBlock_.RTL) ? -size.width / 2 : size.width / 2; +// /** @type {!Element} */ +// this.textElement_ = Blockly.utils.dom.createSvgElement('text', +// { +// 'class': 'blocklyText', +// 'x': fieldX, +// 'dy': '0.6ex', +// 'y': size.height / 2 +// }, +// this.fieldGroup_) as SVGTextElement; + +// this.updateEditable(); +// const svgRoot = (this.sourceBlock_ as Blockly.BlockSvg).getSvgRoot(); +// svgRoot.appendChild(this.fieldGroup_); +// svgRoot.querySelector(".blocklyBlockBackground").setAttribute('fill', (this.sourceBlock_ as Blockly.BlockSvg).getColourTertiary()) + +// this.switchToggle(this.state_); +// this.setValue(this.getValue()); + +// // Force a render. +// this.markDirty(); +// } + +// getDisplayText_() { +// return this.state_ ? this.getTrueText() : this.getFalseText(); +// } + +// getTrueText() { +// return lf("True"); +// } + +// getFalseText() { +// return lf("False"); +// } + +// updateSize_() { +// switch (this.getOutputShape()) { +// case provider.SHAPES.ROUND: +// this.size_.width = this.getInnerWidth() * 2 - 7; break; +// case provider.SHAPES.HEXAGONAL: +// this.size_.width = this.getInnerWidth() * 2 + 8 - Math.floor(this.getInnerWidth() / 2); break; +// case provider.SHAPES.SQUARE: +// this.size_.width = 9 + this.getInnerWidth() * 2; break; +// } +// } + +// getInnerWidth() { +// return this.getMaxLength() * 10; +// } + +// getMaxLength() { +// return Math.max(this.getTrueText().length, this.getFalseText().length); +// } + +// getOutputShape() { +// return this.sourceBlock_.isShadow() ? this.sourceBlock_.getOutputShape() : provider.SHAPES.SQUARE; +// } + +// doClassValidation_(newBool: any) { +// return typeof this.fromVal(newBool) == "boolean" ? newBool : "false"; +// } + +// applyColour() { +// let color = (this.sourceBlock_ as Blockly.BlockSvg).getColourTertiary(); +// if (this.borderRect_) { +// this.borderRect_.setAttribute('stroke', color); +// } else { +// (this.sourceBlock_ as any).pathObject.svgPath.setAttribute('fill', color); +// } +// }; + +// /** +// * Return 'TRUE' if the toggle is ON, 'FALSE' otherwise. +// * @return {string} Current state. +// */ +// getValue() { +// return this.toVal(this.state_); +// }; + +// /** +// * Set the checkbox to be checked if newBool is 'TRUE' or true, +// * unchecks otherwise. +// * @param {string|boolean} newBool New state. +// */ +// doValueUpdate_(newBool: string) { +// let newState = this.fromVal(newBool); +// if (this.state_ !== newState) { +// if (this.sourceBlock_ && Blockly.Events.isEnabled()) { +// Blockly.Events.fire(new (Blockly.Events as any).BlockChange( +// this.sourceBlock_, 'field', this.name, this.state_, newState)); +// } +// this.state_ = newState; + +// this.switchToggle(this.state_); +// this.isDirty_ = true; +// } +// } + +// switchToggle(newState: boolean) { +// if (this.checkElement_) { +// this.updateSize_(); +// const size = this.getSize(); +// const innerWidth = this.getInnerWidth(); +// if (newState) { +// pxt.BrowserUtils.addClass(this.checkElement_, 'blocklyToggleOn'); +// pxt.BrowserUtils.removeClass(this.checkElement_, 'blocklyToggleOff'); +// } else { +// pxt.BrowserUtils.removeClass(this.checkElement_, 'blocklyToggleOn'); +// pxt.BrowserUtils.addClass(this.checkElement_, 'blocklyToggleOff'); +// } +// const outputShape = this.getOutputShape(); +// let width = 0, halfWidth = 0; +// let leftPadding = 0, rightPadding = 0; + +// switch (outputShape) { +// case provider.SHAPES.HEXAGONAL: +// width = size.width / 2; +// halfWidth = width / 2; +// leftPadding = -halfWidth; // total translation when toggle is left-aligned = 0 +// rightPadding = halfWidth - innerWidth; // total translation when right-aligned = width + +// /** +// * Toggle defined clockwise from bottom left: +// * +// * 0, 14 ----------- width, 14 +// * / \ +// * -14, 0 width + 14, 0 +// * \ / +// * 0, -14 ----------- width, -14 +// */ + +// this.toggleThumb_.setAttribute('points', `${0},-14 -14,0 ${0},14 ${width},14 ${width + 14},0 ${width},-14`); +// break; +// case provider.SHAPES.ROUND: +// case provider.SHAPES.SQUARE: +// width = 5 + innerWidth; +// halfWidth = width / 2; +// this.toggleThumb_.setAttribute('width', "" + width); +// this.toggleThumb_.setAttribute('x', `-${halfWidth}`); +// leftPadding = rightPadding = outputShape == provider.SHAPES.SQUARE ? 2 : -6; +// break; +// } +// this.checkElement_.setAttribute('transform', `translate(${newState ? rightPadding + innerWidth + halfWidth : halfWidth + leftPadding}, ${size.height / 2})`); +// } +// } + +// render_() { +// if (this.visible_ && this.textElement_) { +// // Replace the text. +// goog.dom.removeChildren(/** @type {!Element} */(this.textElement_)); +// let textNode = document.createTextNode(this.getDisplayText_()); +// this.textElement_.appendChild(textNode); +// pxt.BrowserUtils.addClass(this.textElement_ as SVGElement, 'blocklyToggleText'); +// this.updateSize_(); + +// // Update text centering, based on newly calculated width. +// let width = this.size_.width; +// let centerTextX = this.state_ ? (width + width / 8) : width / 2; + +// // Apply new text element x position. +// let newX = centerTextX - width / 2; +// this.textElement_.setAttribute('x', `${newX}`); +// } + +// // Update any drawn box to the correct width and height. +// if (this.borderRect_) { +// this.borderRect_.setAttribute('width', `${this.size_.width}`); +// this.borderRect_.setAttribute('height', `${this.size_.height}`); +// } +// } + +// /** +// * Toggle the state of the toggle. +// * @private +// */ +// showEditor_() { +// let newState = !this.state_; +// /* +// if (this.sourceBlock_) { +// // Call any validation function, and allow it to override. +// newState = this.callValidator(newState); +// }*/ +// if (newState !== null) { +// this.setValue(this.toVal(newState)); +// } +// } + +// private toVal(newState: boolean): string { +// if (this.type_ == "number") return String(newState ? '1' : '0'); +// else return String(newState ? 'true' : 'false'); +// } + +// private fromVal(val: any): boolean { +// if (typeof val == "string") { +// if (val == "1" || val.toUpperCase() == "TRUE") return true; +// return false; +// } +// return !!val; +// } +// } \ No newline at end of file diff --git a/newblocks/fields/field_toggle_highlow.ts b/newblocks/fields/field_toggle_highlow.ts new file mode 100644 index 000000000000..a34efbc10ea1 --- /dev/null +++ b/newblocks/fields/field_toggle_highlow.ts @@ -0,0 +1,22 @@ +// /// + +// import * as Blockly from "blockly"; + +// namespace pxtblockly { + +// export class FieldToggleHighLow extends FieldToggle implements Blockly.FieldCustom { +// public isFieldCustom_ = true; + +// constructor(state: string, params: Blockly.FieldCustomOptions, opt_validator?: Function) { +// super(state, params, opt_validator); +// } + +// getTrueText() { +// return lf("HIGH"); +// } + +// getFalseText() { +// return lf("LOW"); +// } +// } +// } \ No newline at end of file diff --git a/newblocks/fields/field_toggle_onoff.ts b/newblocks/fields/field_toggle_onoff.ts new file mode 100644 index 000000000000..7a8c70436b03 --- /dev/null +++ b/newblocks/fields/field_toggle_onoff.ts @@ -0,0 +1,22 @@ +// /// + +// import * as Blockly from "blockly"; + +// namespace pxtblockly { + +// export class FieldToggleOnOff extends FieldToggle implements Blockly.FieldCustom { +// public isFieldCustom_ = true; + +// constructor(state: string, params: Blockly.FieldCustomOptions, opt_validator?: Function) { +// super(state, params, opt_validator); +// } + +// getTrueText() { +// return lf("ON"); +// } + +// getFalseText() { +// return lf("OFF"); +// } +// } +// } \ No newline at end of file diff --git a/newblocks/fields/field_toggle_updown.ts b/newblocks/fields/field_toggle_updown.ts new file mode 100644 index 000000000000..0b964a41fd95 --- /dev/null +++ b/newblocks/fields/field_toggle_updown.ts @@ -0,0 +1,38 @@ +// /// + +// import * as Blockly from "blockly"; + +// namespace pxtblockly { + +// export class FieldToggleUpDown extends FieldToggle implements Blockly.FieldCustom { +// public isFieldCustom_ = true; + +// constructor(state: string, params: Blockly.FieldCustomOptions, opt_validator?: Function) { +// super(state, params, opt_validator); +// } + +// getTrueText() { +// return lf("UP"); +// } + +// getFalseText() { +// return lf("DOWN"); +// } +// } + +// export class FieldToggleDownUp extends FieldToggle implements Blockly.FieldCustom { +// public isFieldCustom_ = true; + +// constructor(state: string, params: Blockly.FieldCustomOptions, opt_validator?: Function) { +// super(state, params, opt_validator); +// } + +// getTrueText() { +// return lf("DOWN"); +// } + +// getFalseText() { +// return lf("UP"); +// } +// } +// } \ No newline at end of file diff --git a/newblocks/fields/field_toggle_winlose.ts b/newblocks/fields/field_toggle_winlose.ts new file mode 100644 index 000000000000..8af80fd0b77a --- /dev/null +++ b/newblocks/fields/field_toggle_winlose.ts @@ -0,0 +1,22 @@ +// /// + +// import * as Blockly from "blockly"; + +// namespace pxtblockly { + +// export class FieldToggleWinLose extends FieldToggle implements Blockly.FieldCustom { +// public isFieldCustom_ = true; + +// constructor(state: string, params: Blockly.FieldCustomOptions, opt_validator?: Function) { +// super(state, params, opt_validator); +// } + +// getTrueText() { +// return lf("WIN"); +// } + +// getFalseText() { +// return lf("LOSE"); +// } +// } +// } \ No newline at end of file diff --git a/newblocks/fields/field_toggle_yesno.ts b/newblocks/fields/field_toggle_yesno.ts new file mode 100644 index 000000000000..56b4291674dd --- /dev/null +++ b/newblocks/fields/field_toggle_yesno.ts @@ -0,0 +1,22 @@ +// /// + +// import * as Blockly from "blockly"; + +// namespace pxtblockly { + +// export class FieldToggleYesNo extends FieldToggle implements Blockly.FieldCustom { +// public isFieldCustom_ = true; + +// constructor(state: string, params: Blockly.FieldCustomOptions, opt_validator?: Function) { +// super(state, params, opt_validator); +// } + +// getTrueText() { +// return lf("Yes"); +// } + +// getFalseText() { +// return lf("No"); +// } +// } +// } \ No newline at end of file diff --git a/newblocks/fields/field_tsexpression.ts b/newblocks/fields/field_tsexpression.ts new file mode 100644 index 000000000000..6e3602da6789 --- /dev/null +++ b/newblocks/fields/field_tsexpression.ts @@ -0,0 +1,50 @@ +/// + +import * as Blockly from "blockly"; +import { FieldCustom } from "./field_utils"; + + +export class FieldTsExpression extends Blockly.FieldTextInput implements FieldCustom { + public isFieldCustom_ = true; + protected pythonMode = false; + + + /** + * Same as parent, but adds a different class to text when disabled + */ + public updateEditable() { + let group = this.fieldGroup_; + if (!this.EDITABLE || !group) { + return; + } + if (this.sourceBlock_.isEditable()) { + pxt.BrowserUtils.addClass(group, 'blocklyEditableText'); + pxt.BrowserUtils.removeClass(group, 'blocklyGreyExpressionBlockText'); + (this.fieldGroup_ as any).style.cursor = this.CURSOR; + } else { + pxt.BrowserUtils.addClass(group, 'blocklyGreyExpressionBlockText'); + pxt.BrowserUtils.removeClass(group, 'blocklyEditableText'); + (this.fieldGroup_ as any).style.cursor = ''; + } + } + + public setPythonEnabled(enabled: boolean) { + if (enabled === this.pythonMode) return; + + this.pythonMode = enabled; + this.forceRerender(); + } + + getText() { + return this.pythonMode ? pxt.Util.lf("") : this.getValue(); + } + + applyColour() { + if (this.sourceBlock_ && this.getConstants()?.FULL_BLOCK_FIELDS) { + if (this.borderRect_) { + this.borderRect_.setAttribute('stroke', + (this.sourceBlock_ as Blockly.BlockSvg).style.colourTertiary); + } + } + } +} \ No newline at end of file diff --git a/newblocks/fields/field_turnratio.ts b/newblocks/fields/field_turnratio.ts new file mode 100644 index 000000000000..94e6415fe257 --- /dev/null +++ b/newblocks/fields/field_turnratio.ts @@ -0,0 +1,109 @@ +// /// + +// import * as Blockly from "blockly"; + +// namespace pxtblockly { + +// export interface FieldTurnRatioOptions extends Blockly.FieldCustomOptions { +// } + +// export class FieldTurnRatio extends Blockly.FieldSlider implements Blockly.FieldCustom { +// public isFieldCustom_ = true; + +// private params: any; + +// private path_: SVGPathElement; +// private reporter_: SVGTextElement; + +// /** +// * Class for a color wheel field. +// * @param {number|string} value The initial content of the field. +// * @param {Function=} opt_validator An optional function that is called +// * to validate any constraints on what the user entered. Takes the new +// * text as an argument and returns either the accepted text, a replacement +// * text, or null to abort the change. +// * @extends {Blockly.FieldNumber} +// * @constructor +// */ +// constructor(value_: any, params: FieldTurnRatioOptions, opt_validator?: Function) { +// super(String(value_), '-200', '200', '1', '10', 'TurnRatio', opt_validator); +// this.params = params; +// (this as any).sliderColor_ = '#a8aaa8'; +// } + +// static HALF = 80; +// static HANDLE_RADIUS = 30; +// static RADIUS = FieldTurnRatio.HALF - FieldTurnRatio.HANDLE_RADIUS - 1; + +// createLabelDom_(labelText: string) { +// let labelContainer = document.createElement('div'); +// let svg = Blockly.utils.dom.createSvgElement('svg', { +// 'xmlns': 'http://www.w3.org/2000/svg', +// 'xmlns:html': 'http://www.w3.org/1999/xhtml', +// 'xmlns:xlink': 'http://www.w3.org/1999/xlink', +// 'version': '1.1', +// 'height': (FieldTurnRatio.HALF + FieldTurnRatio.HANDLE_RADIUS + 10) + 'px', +// 'width': (FieldTurnRatio.HALF * 2) + 'px' +// }, labelContainer); +// let defs = Blockly.utils.dom.createSvgElement('defs', {}, svg); +// let marker = Blockly.utils.dom.createSvgElement('marker', { +// 'id': 'head', +// 'orient': "auto", +// 'markerWidth': '2', +// 'markerHeight': '4', +// 'refX': '0.1', 'refY': '1.5' +// }, defs); +// let markerPath = Blockly.utils.dom.createSvgElement('path', { +// 'd': 'M0,0 V3 L1.5,1.5 Z', +// 'fill': '#f12a21' +// }, marker); +// this.reporter_ = pxsim.svg.child(svg, "text", { +// 'x': FieldTurnRatio.HALF, 'y': 96, +// 'text-anchor': 'middle', 'dominant-baseline': 'middle', +// 'style': 'font-size: 50px', +// 'class': 'sim-text inverted number' +// }) as SVGTextElement; +// this.path_ = Blockly.utils.dom.createSvgElement('path', { +// 'x1': FieldTurnRatio.HALF, +// 'y1': FieldTurnRatio.HALF, +// 'marker-end': 'url(#head)', +// 'style': 'fill: none; stroke: #f12a21; stroke-width: 10' +// }, svg); +// this.updateGraph_(); +// let readout = document.createElement('span'); +// readout.setAttribute('class', 'blocklyFieldSliderReadout'); +// return [labelContainer, readout]; +// }; + +// updateGraph_() { +// if (!this.path_) { +// return; +// } +// let v = goog.math.clamp(this.getValue() || 0, -200, 200); +// const x = v / 100; +// const nx = Math.max(-1, Math.min(1, x)); +// const theta = Math.max(nx) * Math.PI / 2; +// const r = FieldTurnRatio.RADIUS - 6; +// let cx = FieldTurnRatio.HALF; +// const cy = FieldTurnRatio.HALF - 22; +// if (Math.abs(x) > 1) { +// cx -= (x - (x > 0 ? 1 : -1)) * r / 2; // move center of circle +// } +// const alpha = 0.2 + Math.abs(nx) * 0.5; +// const y1 = r * alpha; +// const y2 = r * Math.sin(Math.PI / 2 - theta); +// const x2 = r * Math.cos(Math.PI / 2 - theta); +// const y3 = y2 - r * alpha * Math.cos(2 * theta); +// const x3 = x2 - r * alpha * Math.sin(2 * theta); + +// const d = `M ${cx} ${cy} C ${cx} ${cy - y1} ${cx + x3} ${cy - y3} ${cx + x2} ${cy - y2}`; +// this.path_.setAttribute('d', d); + +// this.reporter_.textContent = `${v}`; +// } + +// setReadout_(readout: Element, value: string) { +// this.updateGraph_(); +// } +// } +// } \ No newline at end of file diff --git a/newblocks/fields/field_userenum.ts b/newblocks/fields/field_userenum.ts new file mode 100644 index 000000000000..b1666cd1439e --- /dev/null +++ b/newblocks/fields/field_userenum.ts @@ -0,0 +1,173 @@ +/// + +import * as Blockly from "blockly"; + +export class FieldUserEnum extends Blockly.FieldDropdown { + constructor(private opts: pxtc.EnumInfo) { + super(createMenuGenerator(opts)); + } + + init() { + super.init(); + this.initVariables(); + } + + onItemSelected_(menu: Blockly.Menu, menuItem: Blockly.MenuItem) { + const value = menuItem.getValue(); + if (value === "CREATE") { + promptAndCreateEnum(this.sourceBlock_.workspace, this.opts, lf("New {0}:", this.opts.memberName), + newName => newName && this.setValue(newName)); + } + else { + super.onItemSelected_(menu, menuItem); + } + } + + doClassValidation_(value: any) { + // update cached option list when adding a new kind + if (this.opts?.initialMembers && !this.opts.initialMembers.find(el => el == value)) this.getOptions(); + return super.doClassValidation_(value); + } + + private initVariables() { + if (this.sourceBlock_ && this.sourceBlock_.workspace) { + const ws = this.sourceBlock_.workspace; + const existing = getMembersForEnum(ws, this.opts.name); + this.opts.initialMembers.forEach(memberName => { + if (!existing.some(([name, value]) => name === memberName)) { + createNewEnumMember(ws, this.opts, memberName); + } + }); + + if (this.getValue() === "CREATE") { + const newValue = getVariableNameForMember(ws, this.opts.name, this.opts.initialMembers[0]) + if (newValue) { + this.setValue(newValue); + } + } + } + } +} + +function createMenuGenerator(opts: pxtc.EnumInfo): () => [string, string][] { + return function (this: FieldUserEnum) { + const res: [string, string][] = []; + + if (this.sourceBlock_ && this.sourceBlock_.workspace) { + const options = this.sourceBlock_.workspace.getVariablesOfType(opts.name); + options.forEach(model => { + // The format of the name is 10mem where "10" is the value and "mem" is the enum member + const withoutValue = model.name.replace(/^\d+/, "") + res.push([withoutValue, model.name]); + }); + } else { + // Can't create variables from within the flyout, so we just have to fake it + opts.initialMembers.forEach((e) => res.push([e, e])); + } + + + res.push([lf("Add a new {0}...", opts.memberName), "CREATE"]); + + return res; + } +} + +function promptAndCreateEnum(ws: Blockly.Workspace, opts: pxtc.EnumInfo, message: string, cb: (newValue: string) => void) { + Blockly.dialog.prompt(message, null, response => { + if (response) { + let nameIsValid = false; + if (pxtc.isIdentifierStart(response.charCodeAt(0), 2)) { + nameIsValid = true; + for (let i = 1; i < response.length; i++) { + if (!pxtc.isIdentifierPart(response.charCodeAt(i), 2)) { + nameIsValid = false; + } + } + } + + if (!nameIsValid) { + Blockly.dialog.alert(lf("Names must start with a letter and can only contain letters, numbers, '$', and '_'."), + () => promptAndCreateEnum(ws, opts, message, cb)); + return; + } + + const existing = getMembersForEnum(ws, opts.name); + for (let i = 0; i < existing.length; i++) { + const [name, value] = existing[i]; + if (name === response) { + Blockly.dialog.alert(lf("A {0} named '{1}' already exists.", opts.memberName, response), + () => promptAndCreateEnum(ws, opts, message, cb)); + return; + } + } + + cb(createNewEnumMember(ws, opts, response)); + } + }/* FIXME (riknoll), { placeholder: opts.promptHint }*/); +} + +function parseName(model: Blockly.VariableModel): [string, number] { + const match = /^(\d+)([^0-9].*)$/.exec(model.name); + + if (match) { + return [match[2], parseInt(match[1])]; + } + return [model.name, -1]; +} + +function getMembersForEnum(ws: Blockly.Workspace, enumName: string): [string, number][] { + const existing = ws.getVariablesOfType(enumName); + if (existing && existing.length) { + return existing.map(parseName); + } + else { + return []; + } +} + +export function getNextValue(members: [string, number][], opts: pxtc.EnumInfo) { + const existing = members.map(([name, value]) => value); + + if (opts.isBitMask) { + for (let i = 0; i < existing.length; i++) { + let current = 1 << i; + + if (existing.indexOf(current) < 0) { + return current; + } + } + return 1 << existing.length; + } else if (opts.isHash) { + return 0; // overriden when compiled + } + else { + const start = opts.firstValue || 0; + for (let i = 0; i < existing.length; i++) { + if (existing.indexOf(start + i) < 0) { + return start + i; + } + } + return start + existing.length; + } +} + +function createNewEnumMember(ws: Blockly.Workspace, opts: pxtc.EnumInfo, newName: string): string { + const ex = getMembersForEnum(ws, opts.name); + const val = getNextValue(ex, opts); + const variableName = val + newName; + (Blockly as any).Variables.getOrCreateVariablePackage(ws, null, variableName, opts.name); + return variableName; +} + +function getVariableNameForMember(ws: Blockly.Workspace, enumName: string, memberName: string): string { + const existing = ws.getVariablesOfType(enumName); + if (existing && existing.length) { + for (let i = 0; i < existing.length; i++) { + const [name,] = parseName(existing[i]); + if (name === memberName) { + return existing[i].name; + } + } + } + return undefined; +} \ No newline at end of file diff --git a/newblocks/fields/field_utils.ts b/newblocks/fields/field_utils.ts new file mode 100644 index 000000000000..481bd5c52bfc --- /dev/null +++ b/newblocks/fields/field_utils.ts @@ -0,0 +1,471 @@ +import * as Blockly from "blockly"; +import { FieldTilemap } from "./field_tilemap"; + +export interface FieldCustom extends Blockly.Field { + isFieldCustom_: boolean; + saveOptions?(): pxt.Map; + restoreOptions?(map: pxt.Map): void; +} + +export interface FieldCustomOptions { + blocksInfo: any; + colour?: string | number; + label?: string; + type?: string; +} + +export interface FieldCustomDropdownOptions extends FieldCustomOptions { + data?: any; +} + +export interface FieldCustomConstructor { + new(text: string, options: FieldCustomOptions, validator?: Function): FieldCustom; +} + +// Parsed format of data stored in the .data attribute of blocks +export interface PXTBlockData { + commentRefs: string[]; + fieldData: pxt.Map; +} + +export namespace svg { + export function hasClass(el: SVGElement, cls: string): boolean { + return pxt.BrowserUtils.containsClass(el, cls); + } + + export function addClass(el: SVGElement, cls: string) { + pxt.BrowserUtils.addClass(el, cls); + } + + export function removeClass(el: SVGElement, cls: string) { + pxt.BrowserUtils.removeClass(el, cls); + } +} + +export function parseColour(colour: string | number): string { + const hue = Number(colour); + if (!isNaN(hue)) { + return Blockly.utils.colour.hueToHex(hue); + } else if (typeof colour === "string" && colour.match(/^#[0-9a-fA-F]{6}$/)) { + return colour as string; + } else { + return '#000'; + } +} + +/** + * Converts a bitmap into a square image suitable for display. In light mode the preview + * is drawn with no transparency (alpha is filled with background color) + */ +export function bitmapToImageURI(frame: pxt.sprite.Bitmap, sideLength: number, lightMode: boolean) { + const colors = pxt.appTarget.runtime.palette.slice(1); + const canvas = document.createElement("canvas"); + canvas.width = sideLength; + canvas.height = sideLength; + + // Works well for all of our default sizes, does not work well if the size is not + // a multiple of 2 or is greater than 32 (i.e. from the decompiler) + const cellSize = Math.min(sideLength / frame.width, sideLength / frame.height); + + // Center the image if it isn't square + const xOffset = Math.max(Math.floor((sideLength * (1 - (frame.width / frame.height))) / 2), 0); + const yOffset = Math.max(Math.floor((sideLength * (1 - (frame.height / frame.width))) / 2), 0); + + let context: CanvasRenderingContext2D; + if (lightMode) { + context = canvas.getContext("2d", { alpha: false }); + context.fillStyle = "#dedede"; + context.fillRect(0, 0, sideLength, sideLength); + } + else { + context = canvas.getContext("2d"); + } + + for (let c = 0; c < frame.width; c++) { + for (let r = 0; r < frame.height; r++) { + const color = frame.get(c, r); + + if (color) { + context.fillStyle = colors[color - 1]; + context.fillRect(xOffset + c * cellSize, yOffset + r * cellSize, cellSize, cellSize); + } + else if (lightMode) { + context.fillStyle = "#dedede"; + context.fillRect(xOffset + c * cellSize, yOffset + r * cellSize, cellSize, cellSize); + } + } + } + + return canvas.toDataURL(); +} + +export function tilemapToImageURI(data: pxt.sprite.TilemapData, sideLength: number, lightMode: boolean) { + const colors = pxt.appTarget.runtime.palette.slice(); + const canvas = document.createElement("canvas"); + canvas.width = sideLength; + canvas.height = sideLength; + + // Works well for all of our default sizes, does not work well if the size is not + // a multiple of 2 or is greater than 32 (i.e. from the decompiler) + const cellSize = Math.min(sideLength / data.tilemap.width, sideLength / data.tilemap.height); + + // Center the image if it isn't square + const xOffset = Math.max(Math.floor((sideLength * (1 - (data.tilemap.width / data.tilemap.height))) / 2), 0); + const yOffset = Math.max(Math.floor((sideLength * (1 - (data.tilemap.height / data.tilemap.width))) / 2), 0); + + let context: CanvasRenderingContext2D; + if (lightMode) { + context = canvas.getContext("2d", { alpha: false }); + context.fillStyle = "#dedede"; + context.fillRect(0, 0, sideLength, sideLength); + } + else { + context = canvas.getContext("2d"); + } + + let tileColors: string[] = []; + + for (let c = 0; c < data.tilemap.width; c++) { + for (let r = 0; r < data.tilemap.height; r++) { + const tile = data.tilemap.get(c, r); + + if (tile) { + if (!tileColors[tile]) { + const tileInfo = data.tileset.tiles[tile]; + tileColors[tile] = tileInfo ? pxt.sprite.computeAverageColor(pxt.sprite.Bitmap.fromData(tileInfo.bitmap), colors) : "#dedede"; + } + + context.fillStyle = tileColors[tile]; + context.fillRect(xOffset + c * cellSize, yOffset + r * cellSize, cellSize, cellSize); + } + else if (lightMode) { + context.fillStyle = "#dedede"; + context.fillRect(xOffset + c * cellSize, yOffset + r * cellSize, cellSize, cellSize); + } + } + } + + return canvas.toDataURL(); +} + +export function songToDataURI(song: pxt.assets.music.Song, width: number, height: number, lightMode: boolean, maxMeasures?: number) { + const colors = pxt.appTarget.runtime.palette.slice(); + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + + let context: CanvasRenderingContext2D; + if (lightMode) { + context = canvas.getContext("2d", { alpha: false }); + context.fillStyle = "#dedede"; + context.fillRect(0, 0, width, height); + } + else { + context = canvas.getContext("2d"); + } + + const trackColors = [ + 5, // duck + 11, // cat + 5, // dog + 4, // fish + 2, // car + 6, // computer + 14, // burger + 2, // cherry + 5, // lemon + 1, // explosion + ] + + maxMeasures = maxMeasures || song.measures; + + const cellWidth = Math.max(Math.floor(width / (song.beatsPerMeasure * maxMeasures * 2)), 1); + const cellsShown = Math.floor(width / cellWidth); + + const cellHeight = Math.max(Math.floor(height / 12), 1); + const notesShown = Math.floor(height / cellHeight); + + for (const track of song.tracks) { + for (const noteEvent of track.notes) { + const col = Math.floor(noteEvent.startTick / (song.ticksPerBeat / 2)); + if (col > cellsShown) break; + + for (const note of noteEvent.notes) { + const row = 12 - (note.note % 12); + if (row > notesShown) continue; + + context.fillStyle = colors[trackColors[track.id || song.tracks.indexOf(track)]]; + context.fillRect(col * cellWidth, row * cellHeight, cellWidth, cellHeight); + } + } + } + + return canvas.toDataURL(); +} + +function deleteTilesetTileIfExists(ws: Blockly.Workspace, tile: pxt.sprite.legacy.LegacyTileInfo) { + const existing = ws.getVariablesOfType(pxt.sprite.BLOCKLY_TILESET_TYPE); + + for (const model of existing) { + if (parseInt(model.name.substr(0, model.name.indexOf(";"))) === tile.projectId) { + ws.deleteVariableById(model.getId()); + break; + } + } +} + +export interface FieldEditorReference { + block: Blockly.Block; + field: string; + ref: U; + parsed?: pxt.sprite.TilemapData; +} + +export function getAllBlocksWithTilemaps(ws: Blockly.Workspace): FieldEditorReference[] { + return getAllFields(ws, f => f instanceof FieldTilemap && !f.isGreyBlock); +} + +// FIXME (riknoll) +// export function getAllBlocksWithTilesets(ws: Blockly.Workspace): FieldEditorReference[] { +// return getAllFields(ws, f => f instanceof FieldTileset); +// } + +export function getAllBlocksWithTilesets(ws: Blockly.Workspace): FieldEditorReference[] { + return getAllFields(ws, f => false) +} + +export function needsTilemapUpgrade(ws: Blockly.Workspace) { + const allTiles = ws.getVariablesOfType(pxt.sprite.BLOCKLY_TILESET_TYPE).map(model => pxt.sprite.legacy.blocklyVariableToTile(model.name)); + return !!allTiles.length; +} + +export function upgradeTilemapsInWorkspace(ws: Blockly.Workspace, proj: pxt.TilemapProject) { + const allTiles = ws.getVariablesOfType(pxt.sprite.BLOCKLY_TILESET_TYPE).map(model => pxt.sprite.legacy.blocklyVariableToTile(model.name)); + if (!allTiles.length) return; + + try { + Blockly.Events.disable(); + let customMapping: pxt.Tile[] = []; + + for (const tile of allTiles) { + if (tile.qualifiedName) { + customMapping[tile.projectId] = proj.resolveTile(tile.qualifiedName); + } + else if (tile.data) { + customMapping[tile.projectId] = proj.createNewTile(tile.data, "myTiles.tile" + tile.projectId); + } + deleteTilesetTileIfExists(ws, tile); + } + + const tilemaps = getAllBlocksWithTilemaps(ws); + + for (const tilemap of tilemaps) { + const legacy = pxt.sprite.legacy.decodeTilemap(tilemap.ref.getInitText(), "typescript"); + + const mapping: pxt.Tile[] = []; + + const newData = new pxt.sprite.TilemapData( + legacy.tilemap, { + tileWidth: legacy.tileset.tileWidth, + tiles: legacy.tileset.tiles.map((t, index) => { + if (t.projectId != null) { + return customMapping[t.projectId]; + } + if (!mapping[index]) { + mapping[index] = proj.resolveTile(t.qualifiedName) + } + + return mapping[index]; + }) + }, + legacy.layers + ); + + tilemap.ref.setValue(pxt.sprite.encodeTilemap(newData, "typescript")); + } + + const tilesets = getAllBlocksWithTilesets(ws); + + for (const tileset of tilesets) { + // Force a re-render + tileset.ref.doValueUpdate_(tileset.ref.getValue()); + if (tileset.ref.isDirty_) { + tileset.ref.forceRerender(); + } + } + } finally { + Blockly.Events.enable(); + } +} + +export function getAllFields(ws: Blockly.Workspace, predicate: (field: Blockly.Field) => boolean): FieldEditorReference[] { + const result: FieldEditorReference[] = []; + + const top = ws.getTopBlocks(false); + top.forEach(block => getAllFieldsRecursive(block)); + + return result; + + function getAllFieldsRecursive(block: Blockly.Block) { + for (const input of block.inputList) { + for (const field of input.fieldRow) { + if (predicate(field)) { + result.push({ block, field: field.name, ref: (field as U) }); + } + } + + if (input.connection && input.connection.targetBlock()) { + getAllFieldsRecursive(input.connection.targetBlock()); + } + } + + if (block.nextConnection && block.nextConnection.targetBlock()) { + getAllFieldsRecursive(block.nextConnection.targetBlock()); + } + } +} + +export function getAllReferencedTiles(workspace: Blockly.Workspace, excludeBlockID?: string) { + let all: pxt.Map = {}; + + const allMaps = getAllBlocksWithTilemaps(workspace); + const project = pxt.react.getTilemapProject(); + + for (const map of allMaps) { + if (map.block.id === excludeBlockID) continue; + + for (const tile of map.ref.getTileset()?.tiles || []) { + all[tile.id] = project.lookupAsset(pxt.AssetType.Tile, tile.id); + } + } + + const projectMaps = project.getAssets(pxt.AssetType.Tilemap); + + for (const projectMap of projectMaps) { + for (const tile of projectMap.data.tileset.tiles) { + all[tile.id] = project.lookupAsset(pxt.AssetType.Tile, tile.id); + } + } + + const allTiles = getAllBlocksWithTilesets(workspace); + for (const tilesetField of allTiles) { + const value = tilesetField.ref.getValue(); + const match = /^\s*assets\s*\.\s*tile\s*`([^`]*)`\s*$/.exec(value); + + if (match) { + const tile = project.lookupAssetByName(pxt.AssetType.Tile, match[1]); + + if (tile && !all[tile.id]) { + all[tile.id] = tile; + } + } + else if (!all[value]) { + all[value] = project.resolveTile(value); + } + } + + return Object.keys(all).map(key => all[key]).filter(t => !!t); +} + +export function getTilesReferencedByTilesets(workspace: Blockly.Workspace) { + let all: pxt.Map = {}; + + const project = pxt.react.getTilemapProject(); + + const allTiles = getAllBlocksWithTilesets(workspace); + for (const tilesetField of allTiles) { + const value = tilesetField.ref.getValue(); + const match = /^\s*assets\s*\.\s*tile\s*`([^`]*)`\s*$/.exec(value); + + if (match) { + const tile = project.lookupAssetByName(pxt.AssetType.Tile, match[1]); + + if (tile && !all[tile.id]) { + all[tile.id] = tile; + } + } + else if (!all[value]) { + all[value] = project.resolveTile(value); + } + } + + return Object.keys(all).map(key => all[key]).filter(t => !!t); +} + +export function getTemporaryAssets(workspace: Blockly.Workspace, type: pxt.AssetType): pxt.Asset[] { + switch (type) { + // FIXME (riknoll) + // case pxt.AssetType.Image: + // return getAllFields(workspace, field => field instanceof FieldSpriteEditor && field.isTemporaryAsset()) + // .map(f => (f.ref as unknown as FieldSpriteEditor).getAsset()); + // case pxt.AssetType.Animation: + // return getAllFields(workspace, field => field instanceof FieldAnimationEditor && field.isTemporaryAsset()) + // .map(f => (f.ref as unknown as FieldAnimationEditor).getAsset()); + // case pxt.AssetType.Song: + // return getAllFields(workspace, field => field instanceof FieldMusicEditor && field.isTemporaryAsset()) + // .map(f => (f.ref as unknown as FieldMusicEditor).getAsset()); + + default: return []; + } +} + +export function setMelodyEditorOpen(block: Blockly.Block, isOpen: boolean) { + // FIXME (riknoll) + // Blockly.Events.fire(new Blockly.Events.Ui(block, "melody-editor", !isOpen, isOpen)); +} + + +export function workspaceToScreenCoordinates(ws: Blockly.WorkspaceSvg, wsCoordinates: Blockly.utils.Coordinate) { + // The position in pixels relative to the origin of the + // main workspace. + const scaledWS = wsCoordinates.scale(ws.scale); + + // The offset in pixels between the main workspace's origin and the upper + // left corner of the injection div. + const mainOffsetPixels = ws.getOriginOffsetInPixels(); + + // The client coordinates offset by the injection div's upper left corner. + const clientOffsetPixels = Blockly.utils.Coordinate.sum( + scaledWS, mainOffsetPixels); + + + const injectionDiv = ws.getInjectionDiv(); + + // Bounding rect coordinates are in client coordinates, meaning that they + // are in pixels relative to the upper left corner of the visible browser + // window. These coordinates change when you scroll the browser window. + const boundingRect = injectionDiv.getBoundingClientRect(); + + return new Blockly.utils.Coordinate(clientOffsetPixels.x + boundingRect.left, + clientOffsetPixels.y + boundingRect.top) +} + +export function getBlockData(block: Blockly.Block): PXTBlockData { + if (!block.data) { + return { + commentRefs: [], + fieldData: {} + }; + } + if (/^(?:\d+;?)+$/.test(block.data)) { + return { + commentRefs: block.data.split(";"), + fieldData: {} + } + } + return JSON.parse(block.data); +} + +export function setBlockData(block: Blockly.Block, data: PXTBlockData) { + block.data = JSON.stringify(data); +} + +export function setBlockDataForField(block: Blockly.Block, field: string, data: string) { + const blockData = getBlockData(block); + blockData.fieldData[field] = data; + setBlockData(block, blockData); +} + +export function getBlockDataForField(block: Blockly.Block, field: string) { + return getBlockData(block).fieldData[field]; +} \ No newline at end of file diff --git a/newblocks/help.ts b/newblocks/help.ts new file mode 100644 index 000000000000..9bdd8083b910 --- /dev/null +++ b/newblocks/help.ts @@ -0,0 +1,80 @@ +/// + +import * as Blockly from "blockly"; + +import { cleanOuterHTML, getFirstChildWithAttr } from "./xml"; +import { promptTranslateBlock } from "./external"; +import { createToolboxBlock } from "./toolbox"; + +export function setBuiltinHelpInfo(block: any, id: string) { + const info = pxt.blocks.getBlockDefinition(id); + setHelpResources(block, id, info.name, info.tooltip, info.url, pxt.toolbox.getNamespaceColor(info.category)); +} + +export function installBuiltinHelpInfo(id: string) { + const info = pxt.blocks.getBlockDefinition(id); + installHelpResources(id, info.name, info.tooltip, info.url, pxt.toolbox.getNamespaceColor(info.category)); +} + +export function setHelpResources(block: any, id: string, name: string, tooltip: any, url: string, colour: string, colourSecondary?: string, colourTertiary?: string, undeletable?: boolean) { + if (tooltip && (typeof tooltip === "string" || typeof tooltip === "function")) block.setTooltip(tooltip); + if (url) block.setHelpUrl(url); + if (colour) block.setColour(colour, colourSecondary, colourTertiary); + if (undeletable) block.setDeletable(false); + + let tb = document.getElementById('blocklyToolboxDefinition'); + let xml: HTMLElement = tb ? getFirstChildWithAttr(tb, "block", "type", id) as HTMLElement : undefined; + block.codeCard = { + header: name, + name: name, + software: 1, + description: typeof tooltip === "function" ? tooltip(block) : tooltip, + blocksXml: xml ? (`` + (cleanOuterHTML(xml) || ``) + "") : undefined, + url: url + }; + if (pxt.Util.isTranslationMode()) { + block.customContextMenu = (options: any[]) => { + const blockd = pxt.blocks.getBlockDefinition(block.type); + if (blockd && blockd.translationIds) { + options.push({ + enabled: true, + text: lf("Translate this block"), + callback: function () { + promptTranslateBlock(id, blockd.translationIds); + } + }) + } + }; + } +} + +export function installHelpResources(id: string, name: string, tooltip: any, url: string, colour: string, colourSecondary?: string, colourTertiary?: string) { + let block = Blockly.Blocks[id]; + let old = block.init; + if (!old) return; + + block.init = function () { + old.call(this); + let block = this; + setHelpResources(this, id, name, tooltip, url, colour, colourSecondary, colourTertiary); + } +} + +export function mkCard(fn: pxtc.SymbolInfo, blockXml: HTMLElement): pxt.CodeCard { + return { + name: fn.namespace + '.' + fn.name, + shortName: fn.name, + description: fn.attributes.jsDoc, + url: fn.attributes.help ? 'reference/' + fn.attributes.help.replace(/^\//, '') : undefined, + blocksXml: `${cleanOuterHTML(blockXml)}`, + } +} + +export function attachCardInfo(blockInfo: pxtc.BlocksInfo, qName: string): pxt.CodeCard | void { + const toModify: pxtc.SymbolInfo = blockInfo.apis.byQName[qName]; + if (toModify) { + const comp = pxt.blocks.compileInfo(toModify); + const xml = createToolboxBlock(blockInfo, toModify, comp); + return mkCard(toModify, xml); + } +} \ No newline at end of file diff --git a/newblocks/importer.ts b/newblocks/importer.ts new file mode 100644 index 000000000000..52fe1ece8f3b --- /dev/null +++ b/newblocks/importer.ts @@ -0,0 +1,350 @@ +/// + +import * as Blockly from "blockly"; +import { blockSymbol, buildinBlockStatements, hasArrowFunction, initializeAndInject } from "./loader"; +import { extensionBlocklyPatch } from "./external"; + +export interface BlockSnippet { + target: string; // pxt.appTarget.id + versions: pxt.TargetVersions; + xml: string[]; // xml for each top level block + extensions?: string[]; // currently unpopulated. list of extensions used in screenshotted projects +} + +export interface DomToWorkspaceOptions { + applyHideMetaComment?: boolean; + keepMetaComments?: boolean; +} + +/** + * Converts a DOM into workspace without triggering any Blockly event. Returns the new block ids + * @param dom + * @param workspace + */ +export function domToWorkspaceNoEvents(dom: Element, workspace: Blockly.Workspace, opts?: DomToWorkspaceOptions): string[] { + pxt.tickEvent(`blocks.domtow`) + let newBlockIds: string[] = []; + try { + Blockly.Events.disable(); + newBlockIds = Blockly.Xml.domToWorkspace(dom, workspace); + applyMetaComments(workspace, opts); + } catch (e) { + pxt.reportException(e); + } finally { + Blockly.Events.enable(); + } + return newBlockIds.filter(id => !!workspace.getBlockById(id)); +} + +function applyMetaComments(workspace: Blockly.Workspace, opts?: DomToWorkspaceOptions) { + // process meta comments + // @highlight -> highlight block + workspace.getAllBlocks(false) + .filter(b => !!b.getCommentText()) + .forEach(b => { + const initialCommentText = b.getCommentText(); + if (/@hide/.test(initialCommentText) && opts?.applyHideMetaComment) { + b.dispose(true); + return; + } + + let newCommentText = initialCommentText; + if (/@highlight/.test(newCommentText)) { + newCommentText = newCommentText.replace(/@highlight/g, '').trim(); + (workspace as Blockly.WorkspaceSvg).highlightBlock?.(b.id, true) + } + if (/@collapsed/.test(newCommentText) && !b.getParent()) { + newCommentText = newCommentText.replace(/@collapsed/g, '').trim(); + b.setCollapsed(true); + } + newCommentText = newCommentText.replace(/@validate-\S+/g, '').trim(); + + if (initialCommentText !== newCommentText && !opts?.keepMetaComments) { + b.setCommentText(newCommentText || null); + } + }); +} + +export function clearWithoutEvents(workspace: Blockly.Workspace) { + pxt.tickEvent(`blocks.clear`) + if (!workspace) return; + try { + Blockly.Events.disable(); + workspace.clear(); + workspace.clearUndo(); + } finally { + Blockly.Events.enable(); + } +} + +// Saves entire workspace, including variables, into an xml string +export function saveWorkspaceXml(ws: Blockly.Workspace, keepIds?: boolean): string { + const xml = Blockly.Xml.workspaceToDom(ws, !keepIds); + const text = Blockly.Xml.domToText(xml); + return text; +} + +// Saves only the blocks xml by iterating over the top blocks +export function saveBlocksXml(ws: Blockly.Workspace, keepIds?: boolean): string[] { + let topBlocks = ws.getTopBlocks(false); + return topBlocks.map(block => { + return Blockly.Xml.domToText(Blockly.Xml.blockToDom(block, !keepIds)); + }); +} + +export function getDirectChildren(parent: Element, tag: string) { + const res: Element[] = []; + for (let i = 0; i < parent.childNodes.length; i++) { + const n = parent.childNodes.item(i) as Element; + if (n.tagName === tag) { + res.push(n); + } + } + return res; +} + +export function getBlocksWithType(parent: Document | Element, type: string) { + return getChildrenWithAttr(parent, "block", "type", type).concat(getChildrenWithAttr(parent, "shadow", "type", type)); +} + +export function getChildrenWithAttr(parent: Document | Element, tag: string, attr: string, value: string) { + return pxt.Util.toArray(parent.getElementsByTagName(tag)).filter(b => b.getAttribute(attr) === value); +} + +export function getFirstChildWithAttr(parent: Document | Element, tag: string, attr: string, value: string) { + const res = getChildrenWithAttr(parent, tag, attr, value); + return res.length ? res[0] : undefined; +} + +export function loadBlocksXml(ws: Blockly.WorkspaceSvg, text: string) { + let xmlBlock = Blockly.utils.xml.textToDom(text); + let block = Blockly.Xml.domToBlock(xmlBlock, ws) as Blockly.BlockSvg; + if (ws.getMetrics) { + let metrics = ws.getMetrics(); + let blockDimensions = block.getHeightWidth(); + block.moveBy( + metrics.viewLeft + (metrics.viewWidth / 2) - (blockDimensions.width / 2), + metrics.viewTop + (metrics.viewHeight / 2) - (blockDimensions.height / 2) + ); + } +} + +/** + * Loads the xml into a off-screen workspace (not suitable for size computations) + */ +export function loadWorkspaceXml(xml: string, skipReport = false, opts?: DomToWorkspaceOptions): Blockly.Workspace { + const workspace = new Blockly.Workspace() as Blockly.WorkspaceSvg; + try { + const dom = Blockly.utils.xml.textToDom(xml); + domToWorkspaceNoEvents(dom, workspace, opts); + return workspace; + } catch (e) { + if (!skipReport) + pxt.reportException(e); + return null; + } +} + +function patchFloatingBlocks(dom: Element, info: pxtc.BlocksInfo) { + const onstarts = getBlocksWithType(dom, ts.pxtc.ON_START_TYPE); + let onstart = onstarts.length ? onstarts[0] : undefined; + if (onstart) { // nothing to do + onstart.removeAttribute("deletable"); + return; + } + + let newnodes: Element[] = []; + + const blocks: pxt.Map = info.blocksById; + + // walk top level blocks + let node = dom.firstElementChild; + let insertNode: Element = undefined; + while (node) { + const nextNode = node.nextElementSibling; + // does this block is disable or have s nested statement block? + const nodeType = node.getAttribute("type"); + if (!node.getAttribute("disabled") && !node.getElementsByTagName("statement").length + && (buildinBlockStatements[nodeType] || + (blocks[nodeType] && blocks[nodeType].retType == "void" && !hasArrowFunction(blocks[nodeType]))) + ) { + // old block, needs to be wrapped in onstart + if (!insertNode) { + insertNode = dom.ownerDocument.createElement("statement"); + insertNode.setAttribute("name", "HANDLER"); + if (!onstart) { + onstart = dom.ownerDocument.createElement("block"); + onstart.setAttribute("type", ts.pxtc.ON_START_TYPE); + newnodes.push(onstart); + } + onstart.appendChild(insertNode); + insertNode.appendChild(node); + + node.removeAttribute("x"); + node.removeAttribute("y"); + insertNode = node; + } else { + // event, add nested statement + const next = dom.ownerDocument.createElement("next"); + next.appendChild(node); + insertNode.appendChild(next); + node.removeAttribute("x"); + node.removeAttribute("y"); + insertNode = node; + } + } + node = nextNode; + } + + newnodes.forEach(n => dom.appendChild(n)); +} + +/** + * Patch to transform old function blocks to new ones, and rename child nodes + */ +function patchFunctionBlocks(dom: Element, info: pxtc.BlocksInfo) { + let functionNodes = pxt.U.toArray(dom.querySelectorAll("block[type=procedures_defnoreturn]")); + functionNodes.forEach(node => { + node.setAttribute("type", "function_definition"); + node.querySelector("field[name=NAME]").setAttribute("name", "function_name"); + }) + + let functionCallNodes = pxt.U.toArray(dom.querySelectorAll("block[type=procedures_callnoreturn]")); + functionCallNodes.forEach(node => { + node.setAttribute("type", "function_call"); + node.querySelector("field[name=NAME]").setAttribute("name", "function_name"); + }) +} + +export function importXml(pkgTargetVersion: string, xml: string, info: pxtc.BlocksInfo, skipReport = false): string { + try { + // If it's the first project we're importing in the session, Blockly is not initialized + // and blocks haven't been injected yet + initializeAndInject(info); + + const parser = new DOMParser(); + const doc = parser.parseFromString(xml, "application/xml"); + + const upgrades = pxt.patching.computePatches(pkgTargetVersion); + if (upgrades) { + // patch block types + upgrades.filter(up => up.type == "blockId") + .forEach(up => Object.keys(up.map).forEach(type => { + getBlocksWithType(doc, type) + .forEach(blockNode => { + blockNode.setAttribute("type", up.map[type]); + pxt.debug(`patched block ${type} -> ${up.map[type]}`); + }); + })) + + // patch block value + upgrades.filter(up => up.type == "blockValue") + .forEach(up => Object.keys(up.map).forEach(k => { + const m = k.split('.'); + const type = m[0]; + const name = m[1]; + getBlocksWithType(doc, type) + .reduce((prev, current) => prev.concat(getDirectChildren(current, "value")), []) + .forEach(blockNode => { + blockNode.setAttribute("name", up.map[k]); + pxt.debug(`patched block value ${k} -> ${up.map[k]}`); + }); + })) + + // patch enum variables + upgrades.filter(up => up.type == "userenum") + .forEach(up => Object.keys(up.map).forEach(k => { + getChildrenWithAttr(doc, "variable", "type", k).forEach(el => { + el.setAttribute("type", up.map[k]); + pxt.debug(`patched enum variable type ${k} -> ${up.map[k]}`); + }) + })); + } + + // Blockly doesn't allow top-level shadow blocks. We've had bugs in the past where shadow blocks + // have ended up as top-level blocks, so promote them to regular blocks just in case + const shadows = getDirectChildren(doc.children.item(0), "shadow"); + for (const shadow of shadows) { + const block = doc.createElement("block"); + shadow.getAttributeNames().forEach(attr => block.setAttribute(attr, shadow.getAttribute(attr))); + for (let j = 0; j < shadow.childNodes.length; j++) { + block.appendChild(shadow.childNodes.item(j)); + } + shadow.replaceWith(block); + } + + // build upgrade map + const enums: pxt.Map = {}; + Object.keys(info.apis.byQName).forEach(k => { + let api = info.apis.byQName[k]; + if (api.kind == pxtc.SymbolKind.EnumMember) + enums[api.namespace + '.' + (api.attributes.blockImportId || api.attributes.block || api.attributes.blockId || api.name)] + = api.namespace + '.' + api.name; + }) + + // walk through blocks and patch enums + const blocks = doc.getElementsByTagName("block"); + for (let i = 0; i < blocks.length; ++i) + patchBlock(info, enums, blocks[i]); + + // patch floating blocks + patchFloatingBlocks(doc.documentElement, info); + + // patch function blocks + patchFunctionBlocks(doc.documentElement, info) + + // apply extension patches + extensionBlocklyPatch(pkgTargetVersion, doc.documentElement); + + // serialize and return + return new XMLSerializer().serializeToString(doc); + } + catch (e) { + if (!skipReport) + pxt.reportException(e); + return xml; + } +} + +function patchBlock(info: pxtc.BlocksInfo, enums: pxt.Map, block: Element): void { + let type = block.getAttribute("type"); + let b = Blockly.Blocks[type]; + let symbol = blockSymbol(type); + if (!symbol || !b) return; + + let comp = pxt.blocks.compileInfo(symbol); + symbol.parameters?.forEach((p, i) => { + let ptype = info.apis.byQName[p.type]; + if (ptype && ptype.kind == pxtc.SymbolKind.Enum) { + let field = getFirstChildWithAttr(block, "field", "name", comp.actualNameToParam[p.name].definitionName); + if (field) { + let en = enums[ptype.name + '.' + field.textContent]; + if (en) field.textContent = en; + } + /* + +Button.AB + + */ + } + }) +} + +export function validateAllReferencedBlocksExist(xml: string) { + pxt.U.assert(!!Blockly?.Blocks, "Called validateAllReferencedBlocksExist before initializing Blockly"); + const dom = Blockly.utils.xml.textToDom(xml); + + const blocks = dom.querySelectorAll("block"); + + for (let i = 0; i < blocks.length; i++) { + if (!Blockly.Blocks[blocks.item(i).getAttribute("type")]) return false; + } + + const shadows = dom.querySelectorAll("shadow"); + + for (let i = 0; i < shadows.length; i++) { + if (!Blockly.Blocks[shadows.item(i).getAttribute("type")]) return false; + } + + return true; +} \ No newline at end of file diff --git a/newblocks/layout.ts b/newblocks/layout.ts new file mode 100644 index 000000000000..4a0d14ce594c --- /dev/null +++ b/newblocks/layout.ts @@ -0,0 +1,568 @@ +/// + +import * as Blockly from "blockly"; +import { Environment, mkEnv } from "./compiler/environment"; +import { getBlockData } from "./loader"; +import { callKey } from "./compiler/compiler"; +import { BlockSnippet, loadWorkspaceXml, saveBlocksXml } from "./importer"; + +export interface FlowOptions { + ratio?: number; + useViewWidth?: boolean; +} + +export function patchBlocksFromOldWorkspace(blockInfo: ts.pxtc.BlocksInfo, oldWs: Blockly.Workspace, newXml: string): string { + const newWs = loadWorkspaceXml(newXml, true); + // position blocks + alignBlocks(blockInfo, oldWs, newWs); + // inject disabled blocks + return injectDisabledBlocks(oldWs, newWs); +} + +function injectDisabledBlocks(oldWs: Blockly.Workspace, newWs: Blockly.Workspace): string { + const oldDom = Blockly.Xml.workspaceToDom(oldWs, true); + const newDom = Blockly.Xml.workspaceToDom(newWs, true); + pxt.Util.toArray(oldDom.childNodes) + .filter((n: ChildNode) => n.nodeType == Node.ELEMENT_NODE && (n as Element).localName == "block" && (n).getAttribute("disabled") == "true") + .filter((n: Element) => !!Blockly.Blocks[n.getAttribute("type")]) + .forEach(n => newDom.appendChild(newDom.ownerDocument.importNode(n, true))); + const updatedXml = Blockly.Xml.domToText(newDom); + return updatedXml; +} + +function alignBlocks(blockInfo: ts.pxtc.BlocksInfo, oldWs: Blockly.Workspace, newWs: Blockly.Workspace) { + let env: Environment; + let newBlocks: pxt.Map; // support for multiple events with similar name + oldWs.getTopBlocks(false).filter(ob => ob.isEnabled()) + .forEach(ob => { + const otp = ob.getRelativeToSurfaceXY(); + if (otp && otp.x != 0 && otp.y != 0) { + if (!env) { + env = mkEnv(oldWs, blockInfo); + newBlocks = {}; + newWs.getTopBlocks(false).forEach(b => { + const nkey = callKey(env, b); + const nbs = newBlocks[nkey] || []; + nbs.push(b); + newBlocks[nkey] = nbs; + }); + } + const oldKey = callKey(env, ob); + const newBlock = (newBlocks[oldKey] || []).shift(); + + // FIXME: I don't think we're really supposed to edit the block coordinate this way + if (newBlock) { + const coord = newBlock.getRelativeToSurfaceXY(); + coord.x = otp.x; + coord.y = otp.y; + } + } + }) +} + +declare function unescape(escapeUri: string): string; + +/** + * Splits a blockly SVG AFTER a vertical layout. This function relies on the ordering + * of blocks / comments to get as getTopBlock(true)/getTopComment(true) + */ +export function splitSvg(svg: SVGSVGElement, ws: Blockly.WorkspaceSvg, emPixels: number = 18): Element { + const comments = ws.getTopComments(true) as Blockly.WorkspaceCommentSvg[]; + const blocks = ws.getTopBlocks(true) as Blockly.BlockSvg[]; + // don't split for a single block + if (comments.length + blocks.length < 2) + return svg; + + const div = document.createElement("div") as HTMLDivElement; + div.className = `blocks-svg-list ${ws.getInjectionDiv().className}` + + function extract( + parentClass: string, + otherClass: string, + blocki: number, + size: { height: number, width: number }, + translate: { x: number, y: number }, + itemClass?: string + ) { + const svgclone = svg.cloneNode(true) as SVGSVGElement; + // collect all blocks + const parentSvg = svgclone.querySelector(`g.blocklyWorkspace > g.${parentClass}`) as SVGGElement; + const otherSvg = svgclone.querySelector(`g.blocklyWorkspace > g.${otherClass}`) as SVGGElement; + const blocksSvg = pxt.Util.toArray(parentSvg.querySelectorAll(`g.blocklyWorkspace > g.${parentClass} > ${itemClass ? ("." + itemClass) : "g[transform]"}`)); + const blockSvg = blocksSvg.splice(blocki, 1)[0]; + if (!blockSvg) { + // seems like no blocks were generated + pxt.log(`missing block, did block failed to load?`) + return; + } + // remove all but the block we care about + blocksSvg.filter(g => g != blockSvg) + .forEach(g => { + g.parentNode.removeChild(g); + }); + // clear transform, remove other group + parentSvg.removeAttribute("transform"); + otherSvg.parentNode.removeChild(otherSvg); + // patch size + blockSvg.setAttribute("transform", `translate(${translate.x}, ${translate.y})`) + const width = (size.width / emPixels) + "em"; + const height = (size.height / emPixels) + "em"; + svgclone.setAttribute("viewBox", `0 0 ${size.width} ${size.height}`) + svgclone.style.width = width; + svgclone.style.height = height; + svgclone.setAttribute("width", width); + svgclone.setAttribute("height", height); + div.appendChild(svgclone); + } + + comments.forEach((comment, commenti) => extract('blocklyBubbleCanvas', 'blocklyBlockCanvas', + commenti, comment.getHeightWidth(), { x: 0, y: 0 }, "blocklyComment")); + blocks.forEach((block, blocki) => { + const size = block.getHeightWidth(); + const translate = { x: 0, y: 0 }; + if (block.hat) { + size.height += emPixels; + translate.y += emPixels; + } + extract('blocklyBlockCanvas', 'blocklyBubbleCanvas', + blocki, size, translate) + }); + return div; +} + +export function verticalAlign(ws: Blockly.WorkspaceSvg, emPixels: number) { + let y = 0 + let comments = ws.getTopComments(true) as Blockly.WorkspaceCommentSvg[]; + comments.forEach(comment => { + comment.moveBy(0, y) + y += comment.getHeightWidth().height + y += emPixels; //buffer + }) + let blocks = ws.getTopBlocks(true) as Blockly.BlockSvg[]; + blocks.forEach((block, bi) => { + // TODO: REMOVE THIS WHEN FIXED IN PXT-BLOCKLY + if (block.hat) + y += emPixels; // hat height + block.moveBy(0, y) + y += block.getHeightWidth().height + y += emPixels; //buffer + }) +} + +export function setCollapsedAll(ws: Blockly.WorkspaceSvg, collapsed: boolean) { + ws.getTopBlocks(false) + .filter(b => b.isEnabled()) + .forEach(b => b.setCollapsed(collapsed)); +} + +// Workspace margins +const marginx = 20; +const marginy = 20; +export function flow(ws: Blockly.WorkspaceSvg, opts?: FlowOptions) { + if (opts) { + if (opts.useViewWidth) { + const metrics = ws.getMetrics(); + + // Only use the width if in portrait, otherwise the blocks are too spread out + if (metrics.viewHeight > metrics.viewWidth) { + flowBlocks(ws.getTopComments(true) as Blockly.WorkspaceCommentSvg[], ws.getTopBlocks(true) as Blockly.BlockSvg[], undefined, metrics.viewWidth) + ws.scroll(marginx, marginy); + return; + } + } + flowBlocks(ws.getTopComments(true) as Blockly.WorkspaceCommentSvg[], ws.getTopBlocks(true) as Blockly.BlockSvg[], opts.ratio); + } + else { + flowBlocks(ws.getTopComments(true) as Blockly.WorkspaceCommentSvg[], ws.getTopBlocks(true) as Blockly.BlockSvg[]); + } + ws.scroll(marginx, marginy); +} + +export function screenshotEnabled(): boolean { + return !pxt.BrowserUtils.isIE() +} + +export function screenshotAsync(ws: Blockly.WorkspaceSvg, pixelDensity?: number, encodeBlocks?: boolean): Promise { + return toPngAsync(ws, pixelDensity, encodeBlocks); +} + +export function toPngAsync(ws: Blockly.WorkspaceSvg, pixelDensity?: number, encodeBlocks?: boolean): Promise { + let blockSnippet: BlockSnippet; + if (encodeBlocks) { + blockSnippet = { + target: pxt.appTarget.id, + versions: pxt.appTarget.versions, + xml: saveBlocksXml(ws).map(text => pxt.Util.htmlEscape(text)) + }; + } + + const density = (pixelDensity | 0) || 4 + return toSvgAsync(ws, density) + .then(sg => { + if (!sg) return Promise.resolve(undefined); + return pxt.BrowserUtils.encodeToPngAsync(sg.xml, + { + width: sg.width, + height: sg.height, + pixelDensity: density, + text: encodeBlocks ? JSON.stringify(blockSnippet, null, 2) : null + }); + }).catch(e => { + pxt.reportException(e); + return undefined; + }) +} + +const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink"; +const MAX_AREA = 120000000; // https://github.com/jhildenbiddle/canvas-size + +export function toSvgAsync(ws: Blockly.WorkspaceSvg, pixelDensity: number): Promise<{ + width: number; height: number; xml: string; +}> { + if (!ws) + return Promise.resolve<{ width: number; height: number; xml: string; }>(undefined); + + const metrics = ws.getBlocksBoundingBox(); + const sg = ws.getParentSvg().cloneNode(true) as SVGElement; + cleanUpBlocklySvg(sg); + + let width = metrics.right - metrics.left; + let height = metrics.bottom - metrics.top; + let scale = 1; + + const area = width * height * Math.pow(pixelDensity, 2); + if (area > MAX_AREA) { + scale = Math.sqrt(MAX_AREA / area); + } + + return blocklyToSvgAsync(sg, metrics.left, metrics.top, width, height, scale); +} + +export function serializeNode(sg: Node): string { + return serializeSvgString(new XMLSerializer().serializeToString(sg)); +} + +export function serializeSvgString(xmlString: string): string { + return xmlString + .replace(new RegExp(' ', 'g'), ' '); // Replace   with   as a workaround for having nbsp missing from SVG xml +} + +export interface BlockSvg { + width: number; height: number; svg: string; xml: string; css: string; +} + +export function cleanUpBlocklySvg(svg: SVGElement): SVGElement { + pxt.BrowserUtils.removeClass(svg, "blocklySvg"); + pxt.BrowserUtils.addClass(svg, "blocklyPreview pxt-renderer classic-theme"); + + // Remove background elements + pxt.U.toArray(svg.querySelectorAll('.blocklyMainBackground,.blocklyScrollbarBackground')) + .forEach(el => { if (el) el.parentNode.removeChild(el) }); + + // Remove connection indicator elements + pxt.U.toArray(svg.querySelectorAll('.blocklyConnectionIndicator,.blocklyInputConnectionIndicator')) + .forEach(el => { if (el) el.parentNode.removeChild(el) }); + + svg.removeAttribute('width'); + svg.removeAttribute('height'); + + pxt.U.toArray(svg.querySelectorAll('.blocklyBlockCanvas,.blocklyBubbleCanvas')) + .forEach(el => el.removeAttribute('transform')); + + // In order to get the Blockly comment's text area to serialize properly they have to have names + const parser = new DOMParser(); + pxt.U.toArray(svg.querySelectorAll('.blocklyCommentTextarea')) + .forEach(el => { + const dom = parser.parseFromString( + '' + pxt.docs.html2Quote((el as any).value), + 'text/html'); + (el as any).textContent = dom.body.textContent; + }); + + return svg; +} + +export function blocklyToSvgAsync(sg: SVGElement, x: number, y: number, width: number, height: number, scale?: number): Promise { + if (!sg.childNodes[0]) + return Promise.resolve(undefined); + + sg.removeAttribute("width"); + sg.removeAttribute("height"); + sg.removeAttribute("transform"); + + let renderWidth = Math.round(width * (scale || 1)); + let renderHeight = Math.round(height * (scale || 1)); + + const xmlString = serializeNode(sg) + .replace(/^\s*]+>/i, '') + .replace(/<\/svg>\s*$/i, '') // strip out svg tag + const svgXml = `${xmlString}`; + const xsg = new DOMParser().parseFromString(svgXml, "image/svg+xml"); + + const cssLink = xsg.createElementNS("http://www.w3.org/1999/xhtml", "style"); + const isRtl = pxt.Util.isUserLanguageRtl(); + const customCssHref = (document.getElementById(`style-${isRtl ? 'rtl' : ''}blockly.css`) as HTMLLinkElement).href; + const semanticCssHref = pxt.Util.toArray(document.head.getElementsByTagName("link")) + .filter(l => pxt.Util.endsWith(l.getAttribute("href"), "semantic.css"))[0].href; + return Promise.all([pxt.BrowserUtils.loadAjaxAsync(customCssHref), pxt.BrowserUtils.loadAjaxAsync(semanticCssHref)]) + .then((customCss) => { + const blocklySvg = pxt.Util.toArray(document.head.querySelectorAll("style")) + .filter((el: HTMLStyleElement) => /\.blocklySvg/.test(el.innerText))[0] as HTMLStyleElement; + // Custom CSS injected directly into the DOM by Blockly + customCss.unshift((document.getElementById(`blockly-common-style`) as HTMLLinkElement)?.innerText || ""); + customCss.unshift((document.getElementById(`blockly-renderer-style-pxt-classic`) as HTMLLinkElement)?.innerText || ""); + // CSS may contain <, > which need to be stored in CDATA section + const cssString = (blocklySvg ? blocklySvg.innerText : "") + '\n\n' + customCss.map(el => el + '\n\n'); + cssLink.appendChild(xsg.createCDATASection(cssString)); + xsg.documentElement.insertBefore(cssLink, xsg.documentElement.firstElementChild); + + return expandImagesAsync(xsg) + .then(() => convertIconsToPngAsync(xsg)) + .then(() => { + return { + width: renderWidth, + height: renderHeight, + svg: serializeNode(xsg).replace('