diff --git a/angular.json b/angular.json index e987836..6664e17 100644 --- a/angular.json +++ b/angular.json @@ -29,15 +29,23 @@ "styles": [ "src/styles.css" ], - "scripts": [] + "scripts": [ + "assets/libs/js-interpreter.js" + ], + "allowedCommonJsDependencies": [ + "blockly", + "blockly/core", + "blockly/msg/en", + "blockly/blocks" + ] }, "configurations": { "production": { "budgets": [ { "type": "initial", - "maximumWarning": "500kB", - "maximumError": "1MB" + "maximumWarning": "2MB", + "maximumError": "5MB" }, { "type": "anyComponentStyle", diff --git a/assets/blocks.json b/assets/blocks.json new file mode 100644 index 0000000..37fbace --- /dev/null +++ b/assets/blocks.json @@ -0,0 +1,184 @@ +[ + { + "type": "event_start", + "message0": "Start program %1 %2", + "style": { + "hat": "cap" + }, + "args0": [ + { + "type": "field_image", + "src": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWZsYWctaWNvbiBsdWNpZGUtZmxhZyI+PHBhdGggZD0iTTQgMTVzMS0xIDQtMSA1IDIgOCAyIDQtMSA0LTFWM3MtMSAxLTQgMS01LTItOC0yLTQgMS00IDF6Ii8+PGxpbmUgeDE9IjQiIHgyPSI0IiB5MT0iMjIiIHkyPSIxNSIvPjwvc3ZnPg==", + "width": 20, + "height": 20, + "alt": "*", + "flipRtl": "FALSE" + }, + { + "type": "input_dummy", + "name": "jump" + } + ], + "nextStatement": null, + "colour": "#ffb309" + }, + { + "type": "movement_jump", + "tooltip": "", + "helpUrl": "", + "message0": "jump to x: %1 y: %2 %3", + "args0": [ + { + "type": "field_number", + "name": "x", + "value": 0 + }, + { + "type": "field_number", + "name": "y", + "value": 0 + }, + { + "type": "input_dummy", + "name": "jump" + } + ], + "previousStatement": null, + "nextStatement": null, + "colour": 285, + "inputsInline": true + }, + { + "type": "movement_turn_left", + "tooltip": "", + "helpUrl": "", + "message0": "turn left %1 %2 %3º", + "args0": [ + { + "type": "field_image", + "src": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLXJvdGF0ZS1jY3ctaWNvbiBsdWNpZGUtcm90YXRlLWNjdyI+PHBhdGggZD0iTTMgMTJhOSA5IDAgMSAwIDktOSA5Ljc1IDkuNzUgMCAwIDAtNi43NCAyLjc0TDMgOCIvPjxwYXRoIGQ9Ik0zIDN2NWg1Ii8+PC9zdmc+", + "width": 20, + "height": 20, + "alt": "*", + "flipRtl": "FALSE" + }, + { + "type": "field_number", + "name": "angle", + "value": 0 + }, + { + "type": "input_dummy" + } + ], + "previousStatement": null, + "nextStatement": null, + "colour": 285, + "inputsInline": true + }, + { + "type": "movement_turn_right", + "tooltip": "", + "helpUrl": "", + "message0": "turn right %1 %2 %3º", + "args0": [ + { + "type": "field_image", + "src": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLXJvdGF0ZS1jdy1pY29uIGx1Y2lkZS1yb3RhdGUtY3ciPjxwYXRoIGQ9Ik0yMSAxMmE5IDkgMCAxIDEtOS05YzIuNTIgMCA0LjkzIDEgNi43NCAyLjc0TDIxIDgiLz48cGF0aCBkPSJNMjEgM3Y1aC01Ii8+PC9zdmc+", + "width": 20, + "height": 20, + "alt": "*", + "flipRtl": "FALSE" + }, + { + "type": "field_number", + "name": "angle", + "value": 0 + }, + { + "type": "input_dummy" + } + ], + "previousStatement": null, + "nextStatement": null, + "colour": 285, + "inputsInline": true + }, + { + "type": "movement_forward", + "tooltip": "", + "helpUrl": "", + "message0": "forward %1 %2", + "args0": [ + { + "type": "field_number", + "name": "steps", + "value": 0 + }, + { + "type": "input_dummy", + "name": "forward" + } + ], + "previousStatement": null, + "nextStatement": null, + "colour": 285 + }, + { + "type": "controls_wait", + "tooltip": "", + "helpUrl": "", + "message0": "wait %1 seconds %2", + "args0": [ + { + "type": "field_number", + "name": "seconds", + "value": 0 + }, + { + "type": "input_dummy", + "name": "NAME" + } + ], + "previousStatement": null, + "nextStatement": null, + "colour": 210 + }, + { + "type": "movement_set_direction", + "tooltip": "", + "helpUrl": "", + "message0": "set direction %1 %2", + "args0": [ + { + "type": "field_number", + "name": "angle", + "value": 0 + }, + { + "type": "input_dummy", + "name": "direction" + } + ], + "previousStatement": null, + "nextStatement": null, + "colour": 285 + }, + { + "type": "controls_repeat_forever", + "tooltip": "", + "helpUrl": "", + "message0": "repeat forever %1 do %2", + "args0": [ + { + "type": "input_dummy" + }, + { + "type": "input_statement", + "name": "statement" + } + ], + "previousStatement": null, + "colour": 120 + } +] diff --git a/assets/libs/js-interpreter.js b/assets/libs/js-interpreter.js new file mode 100644 index 0000000..808e797 --- /dev/null +++ b/assets/libs/js-interpreter.js @@ -0,0 +1,143 @@ +/* + + Copyright 2012 Marijn Haverbeke + SPDX-License-Identifier: MIT +*/ +var p; +var ba="undefined"===typeof globalThis?this||window:globalThis,ca=function(a){function b(f){return 48>f?36===f:58>f?!0:65>f?!1:91>f?!0:97>f?95===f:123>f?!0:170<=f&&Kc.test(String.fromCharCode(f))}function d(f){return 65>f?36===f:91>f?!0:97>f?95===f:123>f?!0:170<=f&&Qb.test(String.fromCharCode(f))}function c(f,h){var l=r;for(var n=1,w=0;;){Ta.lastIndex=w;var K=Ta.exec(l);if(K&&K.indexf)++m;else if(47===f)if(f=r.charCodeAt(m+1),42===f){f=void 0;var h=z.Aa&&z.D&&new g,l=m,n=r.indexOf("*/",m+=2);-1===n&&c(m-2,"Unterminated comment");m=n+2;if(z.D)for(Ta.lastIndex=l;(f=Ta.exec(r))&&f.index=f?Rb(!0):(++m,k(Sb));return;case 40:return++m,k(X);case 41:return++m,k(V);case 59:return++m,k(Y);case 44:return++m,k(ea);case 91:return++m,k(db);case 93:return++m,k(eb);case 123:return++m,k(za);case 125:return++m,k(pa);case 58:return++m,k(Aa);case 63:return++m,k(Tb);case 48:if(f=r.charCodeAt(m+1),120===f||88===f){m+=2;f=Ba(16);null===f&&c(I+2,"Expected hexadecimal number");d(r.charCodeAt(m))&&c(m,"Identifier directly after number");k(Ca,f);return}case 49:case 50:case 51:case 52:case 53:case 54:case 55:case 56:case 57:return Rb(!1); +case 34:case 39:m++;for(var h="";;){m>=oa&&c(I,"Unterminated string constant");var l=r.charCodeAt(m);if(l===f){++m;k(Ua,h);break}if(92===l){l=r.charCodeAt(++m);var n=/^[0-7]+/.exec(r.slice(m,m+3));for(n&&(n=n[0]);n&&255=oa)return k(gb);f=r.charCodeAt(m);if(d(f)||92===f)return $b();if(!1===R(f)){f=String.fromCharCode(f);if("\\"===f||Qb.test(f))return $b();c(m,"Unexpected character '"+f+"'")}}function L(f,h){var l=r.slice(m,m+h);m+=h;k(f,l)}function Ub(){for(var f, +h,l=m;;){m>=oa&&c(l,"Unterminated regexp");var n=r.charAt(m);Wa.test(n)&&c(l,"Unterminated regexp");if(f)f=!1;else{if("["===n)h=!0;else if("]"===n&&h)h=!1;else if("/"===n&&!h)break;f="\\"===n}++m}f=r.slice(l,m);++m;(h=ac())&&!/^[gmi]*$/.test(h)&&c(l,"Invalid regexp flag");try{var w=new RegExp(f,h)}catch(K){throw K instanceof SyntaxError&&c(l,K.message),K;}k(bc,w)}function Ba(f,h){for(var l=m,n=0,w=void 0===h?Infinity:h,K=0;K=O? +O-48:Infinity;if(O>=f)break;++m;n=n*f+O}return m===l||void 0!==h&&m-l!==h?null:n}function Rb(f){var h=m,l=!1,n=48===r.charCodeAt(m);f||null!==Ba(10)||c(h,"Invalid number");46===r.charCodeAt(m)&&(++m,Ba(10),l=!0);f=r.charCodeAt(m);if(69===f||101===f)f=r.charCodeAt(++m),43!==f&&45!==f||++m,null===Ba(10)&&c(h,"Invalid number"),l=!0;d(r.charCodeAt(m))&&c(m,"Identifier directly after number");f=r.slice(h,m);var w;l?w=parseFloat(f):n&&1!==f.length?/[89]/.test(f)||S?c(h,"Invalid number"):w=parseInt(f,8): +w=parseInt(f,10);k(Ca,w)}function Va(f){f=Ba(16,f);null===f&&c(I,"Bad character escape sequence");return f}function ac(){qa=!1;for(var f,h=!0,l=m;;){var n=r.charCodeAt(m);if(b(n))qa&&(f+=r.charAt(m)),++m;else if(92===n){qa||(f=r.slice(l,m));qa=!0;117!==r.charCodeAt(++m)&&c(m,"Expecting Unicode escape sequence \\uXXXX");++m;n=Va(4);var w=String.fromCharCode(n);w||c(m-1,"Invalid Unicode escape");(h?d(n):b(n))||c(m-4,"Invalid Unicode escape");f+=w}else break;h=!1}return qa?f:r.slice(l,m)}function $b(){var f= +ac(),h=ra;!qa&&Vc(f)&&(h=Wc[f]);k(h,f)}function B(){hb=I;fa=na;ib=cb;ha()}function jb(f){S=f;m=I;if(z.D)for(;mh){var w=ia(f);w.left=f;w.operator=T;f=x;B();w.right=wb(xb(),n,l);n=y(w,f===Wb||f===Xb?"LogicalExpression":"BinaryExpression");return wb(n,h,l)}return f}function xb(){if(x.prefix){var f= +M(),h=x.Yb;f.operator=T;ya=f.prefix=!0;B();f.J=xb();h?Ya(f.J):S&&"delete"===f.operator&&"Identifier"===f.J.type&&c(f.start,"Deleting local variable in strict mode");return y(f,h?"UpdateExpression":"UnaryExpression")}for(h=Ga(ab());x.ac&&!Xa();)f=ia(h),f.operator=T,f.prefix=!1,f.J=h,Ya(h),B(),h=y(f,"UpdateExpression");return h}function Ga(f,h){if(E(Sb)){var l=ia(f);l.object=f;l.Ya=aa(!0);l.fb=!1;return Ga(y(l,"MemberExpression"),h)}return E(db)?(l=ia(f),l.object=f,l.Ya=N(),l.fb=!0,F(eb),Ga(y(l,"MemberExpression"), +h)):!h&&E(X)?(l=ia(f),l.callee=f,l.arguments=yb(V,!1),Ga(y(l,"CallExpression"),h)):f}function ab(){switch(x){case tc:var f=M();B();return y(f,"ThisExpression");case ra:return aa();case Ca:case Ua:case bc:return f=M(),f.value=T,f.raw=r.slice(I,na),B(),y(f,"Literal");case uc:case vc:case wc:return f=M(),f.value=x.cb,f.raw=x.l,B(),y(f,"Literal");case X:f=fb;var h=I;B();var l=N();l.start=h;l.end=na;z.D&&(l.O.start=f,l.O.end=cb);z.Za&&(l.j=[h,na]);F(V);return l;case db:return f=M(),B(),f.elements=yb(eb, +!0,!0),y(f,"ArrayExpression");case za:f=M();h=!0;l=!1;f.h=[];for(B();!E(pa);){if(h)h=!1;else if(F(ea),z.sb&&E(pa))break;var n={key:x===Ca||x===Ua?ab():aa(!0)},w=!1;if(E(Aa)){n.value=N(!0);var K=n.kind="init"}else"Identifier"!==n.key.type||"get"!==n.key.name&&"set"!==n.key.name?Z():(w=l=!0,K=n.kind=n.key.name,n.key=x===Ca||x===Ua?ab():aa(!0),x!==X&&Z(),n.value=sb(M(),!1));if("Identifier"===n.key.type&&(S||l))for(var O=0;Ol?f.id:f.sa[l],(yc(n.name)||Za(n.name))&&c(n.start,"Defining '"+n.name+"' in strict mode"),0<=l)for(var w=0;w>>0;return b===Number(a)?b:NaN}function Sa(a){var b=a>>>0;return String(b)===String(a)&&4294967295!==b?b:NaN}function ta(a,b,d){b?a.start=b:delete a.start;d?a.end=d:delete a.end;for(var c in a)if(a[c]!==a.O&&a.hasOwnProperty(c)){var e=a[c];e&&"object"===typeof e&&ta(e,b,d)}}t.prototype.REGEXP_MODE=2;t.prototype.REGEXP_THREAD_TIMEOUT=1E3;t.prototype.POLYFILL_TIMEOUT=1E3;p=t.prototype;p.R=!1;p.Ma=!1;p.Ib=0;p.hc=0; +function da(a,b){var d={},c;for(c in va)d[c]=va[c];d.sourceFile=b;return Pa.j.parse(a,d)}p.Hb=function(a){var b=this.j[0];if(!b||"Program"!==b.node.type)throw Error("Expecting original AST to start with a Program node");"string"===typeof a&&(a=da(a,"appendCode"+this.Ib++));if(!a||"Program"!==a.type)throw Error("Expecting new AST to start with a Program node");bb(this,a,b.scope);Array.prototype.push.apply(b.node.body,a.body);b.node.body.lb=null;b.done=!1}; +p.nb=function(){var a=this.j,b;do{var d=a[a.length-1];if(this.wa)break;else if(!d||"Program"===d.node.type&&d.done){if(!this.$.length)return!1;d=this.$[0];if(!d||d.time>Date.now())d=null;else{this.$.shift();0<=d.j&&Ab(this,d,d.j);var c=new u(d.node,d.scope);d.o&&(c.ma=2,c.C=this.Qa,c.aa=d.o,c.Ta=!0,c.G=d.A);d=c}if(!d)break}c=d.node;var e=Oa;Oa=this;try{var g=this.rb[c.type](a,d,c)}catch(k){if(k!==Ia)throw this.value!==k&&(this.value=void 0),k;}finally{Oa=e}g&&a.push(g);if(this.R)throw this.value= +void 0,Error("Getter not supported in this context");if(this.Ma)throw this.value=void 0,Error("Setter not supported in this context");b||c.end||(b=Date.now()+this.POLYFILL_TIMEOUT)}while(!c.end&&b>Date.now());return!0};p.Cb=function(){for(;!this.wa&&this.nb(););return this.wa};p.Wb=function(){if(this.wa)return ua.ASYNC;var a=this.j;return!(a=a[a.length-1])||"Program"===a.node.type&&a.done?(a=this.$[0])?a.time>Date.now()?ua.TASK:ua.STEP:ua.DONE:ua.STEP}; +function Bb(a,b){a.g(b,"NaN",NaN,xa);a.g(b,"Infinity",Infinity,xa);a.g(b,"undefined",void 0,xa);a.g(b,"window",b,wa);a.g(b,"this",b,xa);a.g(b,"self",b);a.L=new D(null);a.X=new D(a.L);Cb(a,b);Db(a,b);b.Ca=a.L;a.g(b,"constructor",a.u,v);Eb(a,b);Fb(a,b);Gb(a,b);Hb(a,b);Ib(a,b);Jb(a,b);Kb(a,b);Lb(a,b);Mb(a,b);var d=a.i(function(){throw EvalError("Can't happen");},!1);d.eval=!0;a.g(b,"eval",d,v);a.g(b,"parseInt",a.i(parseInt,!1),v);a.g(b,"parseFloat",a.i(parseFloat,!1),v);a.g(b,"isNaN",a.i(isNaN,!1),v); +a.g(b,"isFinite",a.i(isFinite,!1),v);for(var c=[[escape,"escape"],[unescape,"unescape"],[decodeURI,"decodeURI"],[decodeURIComponent,"decodeURIComponent"],[encodeURI,"encodeURI"],[encodeURIComponent,"encodeURIComponent"]],e=0;e>>0;if(!b||0>b)c.length=0;else{b--;var d=c[b];delete c[b];c.length=b;return d}});g("push",function(c){if(!this)throw TypeError();for(var b=Object(this),d=b.length>>>0,a=0;a>>0;if(!b||0>b)c.length=0;else{for(var d=c[0],a=0;a>>0;if(!d||0>d)d=0;for(var a=d-1;0<=a;a--)a in b?b[a+arguments.length]=b[a]:delete b[a+arguments.length];for(a=0;a>>0;if(!b||2>b)return c;for(var d=0;d>>0;b|=0;if(!a||b>=a)return-1;for(b=Math.max(0<=b?b:a-Math.abs(b),0);b>>0;if(!a)return-1;var e=a-1;1>>0;c|=0;c=0<=c?c:Math.max(0,a+c);"undefined"!==typeof b?(Infinity!==b&&(b|=0),b=0>b?a+b:Math.min(b,a)):b=a;b-=c;a=Array(b);for(var e=0;e>>0;c|=0;c=0>c?Math.max(e+\nc,0):Math.min(c,e);b=2>arguments.length?e-c:Math.max(0,Math.min(b|0,e-c));for(var h=[],f=c;f=c;f--)f in a?a[f+k]=a[f]:delete a[f+k];e+=k;for(f=2;f>>0;c="undefined"===typeof c?",":""+c;for(var a="",e=0;e>>0;for(1>>0,e=[],h=2<=arguments.length?arguments[1]:void 0,f=0;f>>0;for(1>>0;1>>0,a=0;if(2===arguments.length)var e=arguments[1];else{for(;a=d)throw TypeError("Reduce of empty array with no initial value");e=b[a++]}for(;a>>0)-1;if(2<=arguments.length)var a=arguments[1];else{for(;0<=d&&!(d in b);)d--;if(0>d)throw TypeError("Reduce of empty array with no initial value");a=b[d--]}for(;0<=d;d--)d in b&&(a=c(a,b[d],d,b));return a});g("some",function(c){if(!this||"function"!==typeof c)throw TypeError();for(var b=Object(this),d=b.length>>>0,a=2<=arguments.length?arguments[1]:void 0,e=0;eString(this[a+1])){var e=this[a],h=a in this;a+1 in this?this[a]=this[a+1]:delete this[a];h?this[a+1]=e:delete this[a+1];d++}if(!d)break}return this});g("toLocaleString",function(){if(!this)throw TypeError();for(var c=Object(this),b=c.length>>>0,d=[],a=0;ab.charCodeAt(0)&&P(this,a,this.F)){var d=Sa(b);if(!isNaN(d)&&d>=":c>>=e;break;case ">>>=":c>>>=e;break;case "&=":c&=e;break;case "^=":c^=e;break;case "|=":c|=e;break;default:throw SyntaxError("Unknown assignment expression: "+d.operator);}if(d=hd(this,b.Ia,c))return b.ya=!0,b.kb=c,ld(this,d,b.Ia,c);a.pop();a[a.length-1].value=c}}; +t.prototype.stepBinaryExpression=function(a,b,d){if(!b.na)return b.na=!0,new u(d.left,b.scope);if(!b.Ga)return b.Ga=!0,b.qa=b.value,new u(d.right,b.scope);a.pop();var c=b.qa;b=b.value;switch(d.operator){case "==":d=c==b;break;case "!=":d=c!=b;break;case "===":d=c===b;break;case "!==":d=c!==b;break;case ">":d=c>b;break;case ">=":d=c>=b;break;case "<":d=c>":d=c>>b;break;case ">>>":d=c>>>b;break;case "in":b instanceof D||H(this,this.o,"'in' expects an object, not '"+b+"'");d=ad(this,b,c);break;case "instanceof":P(this,b,this.P)||H(this,this.o,"'instanceof' expects an object, not '"+b+"'");d=c instanceof D?P(this,c,b):!1;break;default:throw SyntaxError("Unknown binary operator: "+d.operator);}a[a.length-1].value=d}; +t.prototype.stepBlockStatement=function(a,b,d){var c=b.B||0;if(d=d.body[c])return b.B=c+1,new u(d,b.scope);a.pop()};t.prototype.stepBreakStatement=function(a,b,d){id(this,1,void 0,d.label&&d.label.name)};t.prototype.Fb=0; +t.prototype.stepCallExpression=function(a,b,d){if(!b.ma){b.ma=1;var c=new u(d.callee,b.scope);c.xa=!0;return c}if(1===b.ma){b.ma=2;var e=b.value;if(Array.isArray(e)){if(b.aa=gd(this,e),e[0]===Ja?b.Mb="eval"===e[1]:b.C=e[0],e=b.aa,this.R)return b.ma=1,kd(this,e,b.value)}else b.aa=e;b.G=[];b.B=0}e=b.aa;if(!b.Ta){0!==b.B&&b.G.push(b.value);if(d.arguments[b.B])return new u(d.arguments[b.B++],b.scope);if("NewExpression"===d.type){e instanceof D&&!e.zb||H(this,this.o,Q(this,d.callee)+" is not a constructor"); +if(e===this.ua)b.C=Cc(this);else{var g=e.h.prototype;if("object"!==typeof g||null===g)g=this.L;b.C=this.s(g)}b.isConstructor=!0}b.Ta=!0}if(b.hb)a.pop(),a[a.length-1].value=b.isConstructor&&"object"!==typeof b.value?b.C:b.value;else{b.hb=!0;e instanceof D||H(this,this.o,Q(this,d.callee)+" is not a function");if(a=e.node){d=ja(this,a.body,e.Xa);c=Cc(this);for(e=0;ee?b.G[e]: +void 0);d.U||(b.C=md(this,b.C));this.g(d.object,"this",b.C,wa);b.value=void 0;return new u(a.body,d)}if(e.eval)if(e=b.G[0],"string"!==typeof e)b.value=e;else{try{c=da(String(e),"eval"+this.Fb++)}catch(q){H(this,this.Y,"Invalid code: "+q.message)}e=this.Da();e.type="EvalProgram_";e.body=c.body;ta(e,d.start,d.end);d=b.Mb?b.scope:this.M;d.U?d=ja(this,c,d):bb(this,c,d);this.value=void 0;return new u(e,d)}else if(e.Va)b.scope.U||(b.C=md(this,b.C)),b.value=e.Va.apply(b.C,b.G);else if(e.bb){var k=this;c= +e.bb.length-1;c=b.G.concat(Array(c)).slice(0,c);c.push(function(q){b.value=q;k.wa=!1});this.wa=!0;b.scope.U||(b.C=md(this,b.C));e.bb.apply(b.C,c)}else H(this,this.o,Q(this,d.callee)+" is not callable")}}; +t.prototype.stepConditionalExpression=function(a,b,d){var c=b.ra||0;if(0===c)return b.ra=1,new u(d.test,b.scope);if(1===c){b.ra=2;if((c=!!b.value)&&d.fa)return new u(d.fa,b.scope);if(!c&&d.alternate)return new u(d.alternate,b.scope);this.value=void 0}a.pop();"ConditionalExpression"===d.type&&(a[a.length-1].value=b.value)};t.prototype.stepContinueStatement=function(a,b,d){id(this,2,void 0,d.label&&d.label.name)};t.prototype.stepDebuggerStatement=function(a){a.pop()}; +t.prototype.stepDoWhileStatement=function(a,b,d){"DoWhileStatement"===d.type&&void 0===b.ka&&(b.value=!0,b.ka=!0);if(!b.ka)return b.ka=!0,new u(d.test,b.scope);if(!b.value)a.pop();else if(d.body)return b.ka=!1,b.ca=!0,new u(d.body,b.scope)};t.prototype.stepEmptyStatement=function(a){a.pop()};t.prototype.stepEvalProgram_=function(a,b,d){var c=b.B||0;if(d=d.body[c])return b.B=c+1,new u(d,b.scope);a.pop();a[a.length-1].value=this.value}; +t.prototype.stepExpressionStatement=function(a,b,d){if(!b.oa)return this.value=void 0,b.oa=!0,new u(d.pa,b.scope);a.pop();this.value=b.value}; +t.prototype.stepForInStatement=function(a,b,d){if(!b.Rb&&(b.Rb=!0,d.left.ia&&d.left.ia[0].za))return b.scope.U&&H(this,this.Y,"for-in loop variable declaration may not have an initializer"),new u(d.left,b.scope);if(!b.Fa)return b.Fa=!0,b.ta||(b.ta=b.value),new u(d.right,b.scope);b.ca||(b.ca=!0,b.v=b.value,b.mb=Object.create(null));if(void 0===b.Ua)a:for(;;){if(b.v instanceof D)for(b.Ba||(b.Ba=Object.getOwnPropertyNames(b.v.h));;){var c=b.Ba.shift();if(void 0===c)break;if(Object.prototype.hasOwnProperty.call(b.v.h, +c)&&!b.mb[c]&&(b.mb[c]=!0,Object.prototype.propertyIsEnumerable.call(b.v.h,c))){b.Ua=c;break a}}else if(null!==b.v&&void 0!==b.v)for(b.Ba||(b.Ba=Object.getOwnPropertyNames(b.v));;){c=b.Ba.shift();if(void 0===c)break;b.mb[c]=!0;if(Object.prototype.propertyIsEnumerable.call(b.v,c)){b.Ua=c;break a}}b.v=Bc(this,b.v);b.Ba=null;if(null===b.v){a.pop();return}}if(!b.wb)if(b.wb=!0,a=d.left,"VariableDeclaration"===a.type)b.ta=[Ja,a.ia[0].id.name];else return b.ta=null,b=new u(a,b.scope),b.xa=!0,b;b.ta||(b.ta= +b.value);if(!b.ya&&(b.ya=!0,a=b.Ua,c=hd(this,b.ta,a)))return ld(this,c,b.ta,a);b.Ua=void 0;b.wb=!1;b.ya=!1;if(d.body)return new u(d.body,b.scope)};t.prototype.stepForStatement=function(a,b,d){switch(b.ra){default:b.ra=1;if(d.za)return new u(d.za,b.scope);break;case 1:b.ra=2;if(d.test)return new u(d.test,b.scope);break;case 2:b.ra=3;if(d.test&&!b.value)a.pop();else return b.ca=!0,new u(d.body,b.scope);break;case 3:if(b.ra=1,d.update)return new u(d.update,b.scope)}}; +t.prototype.stepFunctionDeclaration=function(a){a.pop()};t.prototype.stepFunctionExpression=function(a,b,d){a.pop();b=a[a.length-1];a=b.scope;d.id&&(a=dd(this,a));b.value=Pb(this,d,a,b.Sa);d.id&&this.g(a.object,d.id.name,b.value,wa)};t.prototype.stepIdentifier=function(a,b,d){a.pop();if(b.xa)a[a.length-1].value=[Ja,d.name];else{b=ed(this,d.name);if(this.R)return kd(this,b,this.Qa);a[a.length-1].value=b}};t.prototype.stepIfStatement=t.prototype.stepConditionalExpression; +t.prototype.stepLabeledStatement=function(a,b,d){a.pop();a=b.labels||[];a.push(d.label.name);b=new u(d.body,b.scope);b.labels=a;return b};t.prototype.stepLiteral=function(a,b,d){a.pop();b=d.value;b instanceof RegExp&&(d=this.s(this.Pa),Ic(this,d,b),b=d);a[a.length-1].value=b}; +t.prototype.stepLogicalExpression=function(a,b,d){if("&&"!==d.operator&&"||"!==d.operator)throw SyntaxError("Unknown logical operator: "+d.operator);if(!b.na)return b.na=!0,new u(d.left,b.scope);if(b.Ga)a.pop(),a[a.length-1].value=b.value;else if("&&"===d.operator&&!b.value||"||"===d.operator&&b.value)a.pop(),a[a.length-1].value=b.value;else return b.Ga=!0,new u(d.right,b.scope)}; +t.prototype.stepMemberExpression=function(a,b,d){if(!b.Fa)return b.Fa=!0,new u(d.object,b.scope);if(d.fb)if(b.Sb)d=b.value;else return b.v=b.value,b.Sb=!0,new u(d.Ya,b.scope);else b.v=b.value,d=d.Ya.name;a.pop();if(b.xa)a[a.length-1].value=[b.v,d];else{d=this.N(b.v,d);if(this.R)return kd(this,d,b.v);a[a.length-1].value=d}};t.prototype.stepNewExpression=t.prototype.stepCallExpression; +t.prototype.stepObjectExpression=function(a,b,d){var c=b.B||0,e=d.h[c];if(b.v){var g=b.Sa;b.La[g]||(b.La[g]={});b.La[g][e.kind]=b.value;b.B=++c;e=d.h[c]}else b.v=this.s(this.L),b.La=Object.create(null);if(e){var k=e.key;if("Identifier"===k.type)g=k.name;else if("Literal"===k.type)g=k.value;else throw SyntaxError("Unknown object structure: "+k.type);b.Sa=g;return new u(e.value,b.scope)}for(k in b.La)d=b.La[k],"get"in d||"set"in d?this.g(b.v,k,Ka,{configurable:!0,enumerable:!0,get:d.get,set:d.set}): +this.g(b.v,k,d.init);a.pop();a[a.length-1].value=b.v};t.prototype.stepProgram=function(a,b,d){if(a=d.body.shift())return b.done=!1,new u(a,b.scope);b.done=!0};t.prototype.stepReturnStatement=function(a,b,d){if(d.J&&!b.oa)return b.oa=!0,new u(d.J,b.scope);id(this,3,b.value)};t.prototype.stepSequenceExpression=function(a,b,d){var c=b.B||0;if(d=d.xb[c])return b.B=c+1,new u(d,b.scope);a.pop();a[a.length-1].value=b.value}; +t.prototype.stepSwitchStatement=function(a,b,d){if(!b.ka)return b.ka=1,new u(d.Nb,b.scope);1===b.ka&&(b.ka=2,b.fc=b.value,b.gb=-1);for(;;){var c=b.jb||0,e=d.tb[c];if(b.Ka||!e||e.test)if(e||b.Ka||-1===b.gb)if(e){if(!b.Ka&&!b.Db&&e.test)return b.Db=!0,new u(e.test,b.scope);if(b.Ka||b.value===b.fc){b.Ka=!0;var g=b.B||0;if(e.fa[g])return b.Xb=!0,b.B=g+1,new u(e.fa[g],b.scope)}b.Db=!1;b.B=0;b.jb=c+1}else{a.pop();break}else b.Ka=!0,b.jb=b.gb;else b.gb=c,b.jb=c+1}}; +t.prototype.stepThisExpression=function(a){a.pop();a[a.length-1].value=ed(this,"this")};t.prototype.stepThrowStatement=function(a,b,d){if(b.oa)H(this,b.value);else return b.oa=!0,new u(d.J,b.scope)}; +t.prototype.stepTryStatement=function(a,b,d){if(!b.Ob)return b.Ob=!0,new u(d.block,b.scope);if(b.ha&&4===b.ha.type&&!b.Qb&&d.Ha)return b.Qb=!0,a=dd(this,b.scope),this.g(a.object,d.Ha.Wa.name,b.ha.value),b.ha=void 0,new u(d.Ha.body,a);if(!b.Pb&&d.ib)return b.Pb=!0,new u(d.ib,b.scope);a.pop();b.ha&&id(this,b.ha.type,b.ha.value,b.ha.label)}; +t.prototype.stepUnaryExpression=function(a,b,d){if(!b.oa)return b.oa=!0,a=new u(d.J,b.scope),a.xa="delete"===d.operator,a;a.pop();var c=b.value;switch(d.operator){case "-":c=-c;break;case "+":c=+c;break;case "!":c=!c;break;case "~":c=~c;break;case "delete":d=!0;if(Array.isArray(c)){var e=c[0];e===Ja&&(e=b.scope);c=String(c[1]);try{delete e.h[c]}catch(g){b.scope.U?H(this,this.o,"Cannot delete property '"+c+"' of '"+e+"'"):d=!1}}c=d;break;case "typeof":c=c&&"Function"===c.H?"function":typeof c;break; +case "void":c=void 0;break;default:throw SyntaxError("Unknown unary operator: "+d.operator);}a[a.length-1].value=c}; +t.prototype.stepUpdateExpression=function(a,b,d){if(!b.na)return b.na=!0,a=new u(d.J,b.scope),a.xa=!0,a;b.Ja||(b.Ja=b.value);b.Ea&&(b.qa=b.value);if(!b.Ea){var c=gd(this,b.Ja);b.qa=c;if(this.R)return b.Ea=!0,kd(this,c,b.Ja)}if(b.ya)a.pop(),a[a.length-1].value=b.kb;else{c=Number(b.qa);if("++"===d.operator)var e=c+1;else if("--"===d.operator)e=c-1;else throw SyntaxError("Unknown update expression: "+d.operator);d=d.prefix?e:c;if(c=hd(this,b.Ja,e))return b.ya=!0,b.kb=d,ld(this,c,b.Ja,e);a.pop();a[a.length- +1].value=d}};t.prototype.stepVariableDeclaration=function(a,b,d){d=d.ia;var c=b.B||0,e=d[c];b.Ab&&e&&(fd(this,e.id.name,b.value),b.Ab=!1,e=d[++c]);for(;e;){if(e.za)return b.B=c,b.Ab=!0,b.Sa=e.id.name,new u(e.za,b.scope);e=d[++c]}a.pop()};t.prototype.stepWithStatement=function(a,b,d){if(!b.Fa)return b.Fa=!0,new u(d.object,b.scope);a.pop();a=dd(this,b.scope,b.value);return new u(d.body,a)};t.prototype.stepWhileStatement=t.prototype.stepDoWhileStatement;Pa.Interpreter=t;t.prototype.step=t.prototype.nb; +t.prototype.run=t.prototype.Cb;t.prototype.getStatus=t.prototype.Wb;t.prototype.appendCode=t.prototype.Hb;t.prototype.createObject=t.prototype.ga;t.prototype.createObjectProto=t.prototype.s;t.prototype.createNativeFunction=t.prototype.i;t.prototype.createAsyncFunction=t.prototype.ub;t.prototype.getProperty=t.prototype.N;t.prototype.setProperty=t.prototype.g;t.prototype.nativeToPseudo=t.prototype.S;t.prototype.pseudoToNative=t.prototype.T;t.prototype.getGlobalScope=t.prototype.Ub; +t.prototype.setGlobalScope=t.prototype.cc;t.prototype.getStateStack=t.prototype.Vb;t.prototype.setStateStack=t.prototype.dc;t.Status=ua;t.VALUE_IN_DESCRIPTOR=Ka; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e81c1bf..c60ca91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3520,6 +3520,10 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + js-interpreter@6.0.1: + resolution: {integrity: sha512-XfPw6y1FzFwHcGYB62jzPUoSCoCSIL+dICMjRJx6f8V/AmTczeodDOaVxWc4GU4p7qeN7ieuMXNKxScoaBkJ6A==} + hasBin: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -9326,6 +9330,10 @@ snapshots: jiti@1.21.7: {} + js-interpreter@6.0.1: + dependencies: + minimist: 1.2.8 + js-tokens@4.0.0: {} js-yaml@3.14.1: @@ -9679,8 +9687,7 @@ snapshots: dependencies: brace-expansion: 2.0.1 - minimist@1.2.8: - optional: true + minimist@1.2.8: {} minipass-collect@2.0.1: dependencies: diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 3ba7bb8..0034ae9 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,7 +1,8 @@ -import { Component } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import {Component} from '@angular/core'; +import {RouterOutlet} from '@angular/router'; import {HeaderComponent} from './layout/header/header.component'; import {FooterComponent} from './layout/footer/footer.component'; +import {BlocklyService} from './services/blockly.service'; @Component({ selector: 'blearn-root', @@ -10,5 +11,8 @@ import {FooterComponent} from './layout/footer/footer.component'; styleUrl: './app.component.css' }) export class AppComponent { - + constructor(blocklyService: BlocklyService) { + blocklyService.defineCustomBlocks(); + blocklyService.defineCodeGenerationForCustomBlocks(); + } } diff --git a/src/app/components/activity/activity.component.spec.ts b/src/app/components/activity/activity.component.spec.ts index b65557a..676e10c 100644 --- a/src/app/components/activity/activity.component.spec.ts +++ b/src/app/components/activity/activity.component.spec.ts @@ -19,7 +19,11 @@ describe('ActivityComponent', () => { title: 'Activity 1', description: 'Description', dueDate: '03/03', - workspace: '{}' + workspace: '{}', + toolboxInfo: { + toolboxDefinition: '', + BLOCK_LIMITS: {} + } }; it('should toggle the menu when clicking the button', async () => { diff --git a/src/app/components/blockly-editor/blockly-editor.component.html b/src/app/components/blockly-editor/blockly-editor.component.html index 38fe985..beabbc5 100644 --- a/src/app/components/blockly-editor/blockly-editor.component.html +++ b/src/app/components/blockly-editor/blockly-editor.component.html @@ -1 +1 @@ -
+
diff --git a/src/app/components/blockly-editor/blockly-editor.component.ts b/src/app/components/blockly-editor/blockly-editor.component.ts index ac68453..a46b0af 100644 --- a/src/app/components/blockly-editor/blockly-editor.component.ts +++ b/src/app/components/blockly-editor/blockly-editor.component.ts @@ -1,6 +1,18 @@ -import {AfterViewInit, Component, ElementRef, Input, OnChanges, OnInit, SimpleChanges, ViewChild} from '@angular/core'; +import { + AfterViewInit, + Component, + ElementRef, + EventEmitter, + inject, + Input, + Output, + signal, + ViewChild +} from '@angular/core'; import * as Blockly from 'blockly'; +import {WorkspaceSvg} from 'blockly'; import 'blockly/blocks'; +import {ModeService} from '../../services/mode.service'; @Component({ selector: 'blearn-blockly-editor', @@ -8,37 +20,49 @@ import 'blockly/blocks'; templateUrl: './blockly-editor.component.html', }) export class BlocklyEditorComponent implements AfterViewInit { + private modeService = inject(ModeService); + @ViewChild('blocklyDiv') blocklyDiv!: ElementRef; + //@ViewChild('blocklyArea') blocklyArea!: ElementRef; + + @Input() toolbox = signal({ + kind: 'flyoutToolbox', + contents: [ + {kind: '', text: '', callbackKey: ''}, + {kind: '', type: ''}, + ] + }); @Input() workspaceJSON!: string; + @Input() BLOCKS_LIMITS: Map = new Map(); + @Output() openModal = new EventEmitter(); + @Output() updateLimits = new EventEmitter(); + @Output() saveWorkspace = new EventEmitter(); + private workspace!: Blockly.WorkspaceSvg; ngAfterViewInit(): void { - this.workspace = Blockly.inject(this.blocklyDiv.nativeElement, { - toolbox: { - kind: 'flyoutToolbox', + this.initBlockly(); + } + + private initBlockly() { + if (this.modeService.getMode() === 'teacher') { + const newToolbox = { + ...this.toolbox(), contents: [ { - kind: 'block', - type: 'procedures_defnoreturn' - }, - { - kind: 'block', - type: 'text_print' - }, - { - kind: 'block', - type: 'text_print' - }, - { - kind: 'block', - type: 'controls_if' + kind: 'button', + text: 'Add / Remove blocks', + callbackKey: 'addNewBlock' }, - { - kind: 'block', - type: 'controls_whileUntil' - } + ...this.toolbox().contents ], - }, + }; + this.toolbox.set(newToolbox); + } + + this.workspace = Blockly.inject(this.blocklyDiv.nativeElement, { + toolbox: this.toolbox(), + renderer: 'Zelos', grid: { colour: '#ccc', snap: true, @@ -48,12 +72,29 @@ export class BlocklyEditorComponent implements AfterViewInit { trashcan: true, scrollbars: true, }); + + this.workspace.addChangeListener((event) => { + if ( + event.type === Blockly.Events.BLOCK_CREATE || + event.type === Blockly.Events.BLOCK_DELETE + ) { + this.updateLimits.emit(); + } + this.saveWorkspace.emit(); + }); + + this.workspace.registerButtonCallback('addNewBlock', () => { + //this.openBlocksModal(); + this.openModal.emit(); + }); + + //this.resizeBlockly(); + const jsonWorkspace = JSON.parse(this.workspaceJSON); Blockly.serialization.workspaces.load(jsonWorkspace, this.workspace); } - saveWorkspaceAsJson(): string { - const jsonWorkspace = Blockly.serialization.workspaces.save(this.workspace); - return JSON.stringify(jsonWorkspace); + public getWorkspace(): WorkspaceSvg { + return this.workspace; } } diff --git a/src/app/components/blocks-modal/blocks-modal.component.html b/src/app/components/blocks-modal/blocks-modal.component.html new file mode 100644 index 0000000..39c14f2 --- /dev/null +++ b/src/app/components/blocks-modal/blocks-modal.component.html @@ -0,0 +1,31 @@ +
+
+
+

Select the blocks

+ +
+
+ @for (block of blockTypes; track block) { +
+
+ +

{{BLOCKS_LIMIT.get(block) || 0}}

+ +
+ } +
+
+
diff --git a/src/app/components/blocks-modal/blocks-modal.component.ts b/src/app/components/blocks-modal/blocks-modal.component.ts new file mode 100644 index 0000000..7f9e3a0 --- /dev/null +++ b/src/app/components/blocks-modal/blocks-modal.component.ts @@ -0,0 +1,82 @@ +import {AfterViewInit, Component, EventEmitter, Input, Output, signal} from '@angular/core'; +import {ButtonComponent} from '../button/button.component'; +import * as Blockly from 'blockly'; + +@Component({ + selector: 'blearn-blocks-modal', + imports: [ + ButtonComponent + ], + templateUrl: './blocks-modal.component.html', +}) +export class BlocksModalComponent implements AfterViewInit { + @Input() BLOCKS_LIMIT: Map = new Map(); + @Output() blockAdded = new EventEmitter(); + @Output() blockRemoved = new EventEmitter(); + @Output() close = new EventEmitter(); + + protected blockTypes = [ + 'event_start', + 'controls_repeat', + 'controls_repeat_forever', + 'controls_if', + 'controls_wait', + 'movement_jump', + 'movement_turn_left', + 'movement_turn_right', + 'movement_forward', + 'movement_set_direction' + ]; + + ngAfterViewInit(): void { + this.renderBlocks() + } + + private renderBlocks() { + const list = document.querySelector('#list')!; + this.blockTypes.forEach((block) => { + const tmpWorkspace = Blockly.inject(document.getElementById(block)!, { + toolbox: { + kind: 'flyoutToolbox', + contents: [ + {kind: '', type: ''}, + ] + }, + readOnly: true, + renderer: 'Zelos', + scrollbars: false, + move: { + scrollbars: {horizontal: false, vertical: true}, + drag: false, + wheel: false, + }, + zoom: { + controls: false, + wheel: false, + }, + }); + + const rect = list.querySelectorAll("rect"); + rect.forEach((r) => { + r.style.display = "none"; + }); + + const newBlock = tmpWorkspace.newBlock(block); + newBlock.initSvg(); + newBlock.render(); + tmpWorkspace.zoomToFit(); + }); + } + + protected addBlock(type: string) { + this.blockAdded.emit(type); + } + + protected removeBlock(type: string) { + this.blockRemoved.emit(type); + } + + protected onClose() { + this.close.emit(); + } +} diff --git a/src/app/components/button/button.component.html b/src/app/components/button/button.component.html index 3de5e95..d971bbb 100644 --- a/src/app/components/button/button.component.html +++ b/src/app/components/button/button.component.html @@ -1,6 +1,10 @@ diff --git a/src/app/components/button/button.component.ts b/src/app/components/button/button.component.ts index efb8e6b..830d48a 100644 --- a/src/app/components/button/button.component.ts +++ b/src/app/components/button/button.component.ts @@ -1,4 +1,4 @@ -import {ChangeDetectionStrategy, Component, computed, inject, Input} from '@angular/core'; +import {ChangeDetectionStrategy, Component, computed, EventEmitter, inject, Input, Output} from '@angular/core'; import {ModeService} from '../../services/mode.service'; import {NgClass} from '@angular/common'; @@ -17,7 +17,14 @@ export class ButtonComponent { @Input() teacherText: string = ''; @Input() studentStyle: string = ''; @Input() teacherStyle: string = ''; + @Input() disabled: boolean = false; + + @Output() clicked = new EventEmitter(); buttonText = computed(() => (this.modeService.getMode() === 'student' ? this.studentText : this.teacherText)); buttonStyle = computed(() => (this.modeService.getMode() === 'student' ? this.studentStyle : this.teacherStyle)); + + protected onClick() { + if (!this.disabled) this.clicked.emit(); + } } diff --git a/src/app/components/description-modal/description-modal.component.html b/src/app/components/description-modal/description-modal.component.html new file mode 100644 index 0000000..c3485f1 --- /dev/null +++ b/src/app/components/description-modal/description-modal.component.html @@ -0,0 +1,35 @@ +
+
+
+
+

{{ activity()!.title }}

+
+

Due on:

+ @if (modeService.getMode() === 'student') { +

{{ activity()!.dueDate | date: 'MM/dd/yyyy' }}

+ } @else { + + } +
+
+ + @let closeStyle = 'bg-red-500 text-white'; + +
+
+

Description

+ @if (modeService.getMode() === 'student') { +

{{activity()!.description}}

+ } @else { + + } +
+
+
diff --git a/src/app/components/description-modal/description-modal.component.ts b/src/app/components/description-modal/description-modal.component.ts new file mode 100644 index 0000000..f122031 --- /dev/null +++ b/src/app/components/description-modal/description-modal.component.ts @@ -0,0 +1,32 @@ +import {Component, EventEmitter, inject, Input, Output, signal} from '@angular/core'; +import {ButtonComponent} from '../button/button.component'; +import {Activity} from '../../models/activity'; +import {FormsModule} from '@angular/forms'; +import {ModeService} from '../../services/mode.service'; +import {DatePipe} from '@angular/common'; + +@Component({ + selector: 'blearn-description-modal', + imports: [ + ButtonComponent, + FormsModule, + DatePipe + ], + templateUrl: './description-modal.component.html', +}) +export class DescriptionModalComponent { + protected modeService = inject(ModeService); + + @Input() activity = signal(null); + @Output() close = new EventEmitter(); + @Output() dueDateUpdated = new EventEmitter(); + @Output() descriptionUpdated = new EventEmitter(); + + protected updateDueDate(newDate: string) { + this.dueDateUpdated.emit(newDate); + } + + protected updateDescription(newDescription: string) { + this.descriptionUpdated.emit(newDescription); + } +} diff --git a/src/app/components/scene/scene.component.html b/src/app/components/scene/scene.component.html new file mode 100644 index 0000000..07091ac --- /dev/null +++ b/src/app/components/scene/scene.component.html @@ -0,0 +1,29 @@ +
+
+ + + @if (modeService.getMode() === 'teacher') { + + } +
+ + +
diff --git a/src/app/components/scene/scene.component.ts b/src/app/components/scene/scene.component.ts new file mode 100644 index 0000000..e44623f --- /dev/null +++ b/src/app/components/scene/scene.component.ts @@ -0,0 +1,148 @@ +import { + AfterViewInit, + Component, + ElementRef, + EventEmitter, + inject, + Input, + Output, + signal, + ViewChild +} from '@angular/core'; +import {SceneObject} from '../../models/scene-object'; +import {ButtonComponent} from '../button/button.component'; +import {ModeService} from '../../services/mode.service'; + +@Component({ + selector: 'blearn-scene', + imports: [ + ButtonComponent + ], + templateUrl: './scene.component.html', +}) +export class SceneComponent implements AfterViewInit { + protected modeService = inject(ModeService); + + @ViewChild('canvas') canvas!: ElementRef; + @ViewChild('scene') scene!: ElementRef; + + @Input() isRunning = signal(false); + @Output() runCode = new EventEmitter(); + @Output() stopCode = new EventEmitter(); + + private ctx: CanvasRenderingContext2D | null = null; + protected sceneObjects: Array = []; + private draggingObject: SceneObject | null = null; + private offsetX: number = 0; + private offsetY: number = 0; + + ngAfterViewInit(): void { + this.initCanvas(); + } + + private initCanvas() { + this.ctx = this.canvas.nativeElement.getContext('2d'); + + // Initialize the canvas size based on the scene + this.canvas.nativeElement.width = this.scene.nativeElement.offsetWidth; + this.canvas.nativeElement.height = this.scene.nativeElement.offsetHeight; + + // Create an image object + const img = new Image(); + img.src = 'https://avatars.githubusercontent.com/u/105555875?v=4'; // Replace with your image URL + img.onload = () => { + // Once the image is loaded, draw it to the canvas + this.sceneObjects.push({img, x: 50, y: 50, rotation: 0, width: 100, height: 100}); + this.drawImages(); + }; + + // Set up mouse event listeners for dragging + this.setupMouseEvents(); + } + + protected addObject() { + const img = new Image(); + img.src = 'https://avatars.githubusercontent.com/u/105555875?v=4'; // Replace with your image URL + img.onload = () => { + // Once the image is loaded, draw it to the canvas + this.sceneObjects.push({img, x: 50, y: 50, rotation: 0, width: 100, height: 100}); + this.drawImages(); + }; + } + + private setupMouseEvents() { + this.canvas.nativeElement.addEventListener('mousedown', (e: MouseEvent) => { + const mouseX = e.offsetX; + const mouseY = e.offsetY; + + // Check if the mouse click is on one of the images + for (let sceneObj of this.sceneObjects) { + if (mouseX >= sceneObj.x && mouseX <= sceneObj.x + sceneObj.width && mouseY >= sceneObj.y && mouseY <= sceneObj.y + sceneObj.height) { + this.draggingObject = sceneObj; + this.offsetX = mouseX - sceneObj.x; + this.offsetY = mouseY - sceneObj.y; + } + } + }); + + this.canvas.nativeElement.addEventListener('mousemove', (e: MouseEvent) => { + if (this.draggingObject) { + const mouseX = e.offsetX; + const mouseY = e.offsetY; + + // Move the image based on mouse position + this.draggingObject.x = mouseX - this.offsetX; + this.draggingObject.y = mouseY - this.offsetY; + + this.drawImages(); + } + }); + + this.canvas.nativeElement.addEventListener('mouseup', () => { + this.draggingObject = null; + }); + + this.canvas.nativeElement.addEventListener('mouseleave', () => { + this.draggingObject = null; + }); + } + + private drawImages() { + if (!this.ctx) return; + + // Clear the canvas + this.ctx.clearRect(0, 0, this.canvas.nativeElement.width, this.canvas.nativeElement.height); + + // Draw each image on the canvas + for (let imgObj of this.sceneObjects) { + this.ctx.drawImage(imgObj.img, imgObj.x, imgObj.y, imgObj.width, imgObj.height); + } + } + + moveTo(x: number, y: number) { + if (this.sceneObjects.length > 0) { + const obj = this.sceneObjects[0]; + obj.x = x; + obj.y = y; + this.drawImages(); + } + } + + moveForward(steps: number) { + const obj = this.sceneObjects[0]; + obj.x += steps; + this.drawImages(); + } + + setDirection(angle: number) { + + } + + turnLeft(angle: number) { + + } + + turnRight(angle: number) { + + } +} diff --git a/src/app/models/activity.ts b/src/app/models/activity.ts index b1cb028..bafd458 100644 --- a/src/app/models/activity.ts +++ b/src/app/models/activity.ts @@ -4,4 +4,8 @@ export interface Activity { description: string, dueDate: string, workspace: string, + toolboxInfo: { + BLOCK_LIMITS: { [key: string]: number }, + toolboxDefinition: string, + } } diff --git a/src/app/models/scene-object.ts b/src/app/models/scene-object.ts new file mode 100644 index 0000000..0fdd024 --- /dev/null +++ b/src/app/models/scene-object.ts @@ -0,0 +1,8 @@ +export interface SceneObject { + img: HTMLImageElement; + x: number; + y: number; + rotation: number; + width: number; + height: number; +} diff --git a/src/app/pages/activity-detail/activity-detail.component.html b/src/app/pages/activity-detail/activity-detail.component.html index c675d7f..4d04035 100644 --- a/src/app/pages/activity-detail/activity-detail.component.html +++ b/src/app/pages/activity-detail/activity-detail.component.html @@ -1,5 +1,8 @@ -
-
+
+
@if (modeService.getMode() === 'student') { } + @let style = "text-white"; - @let buttonText = "Download activity"; - + + +
+
+
+ +
+
- -

{{ activity()?.description }}

- - +
diff --git a/src/app/pages/activity-detail/activity-detail.component.ts b/src/app/pages/activity-detail/activity-detail.component.ts index eb6c9aa..ad048c7 100644 --- a/src/app/pages/activity-detail/activity-detail.component.ts +++ b/src/app/pages/activity-detail/activity-detail.component.ts @@ -1,4 +1,14 @@ -import {Component, computed, inject, signal, ViewChild} from '@angular/core'; +import { + AfterViewInit, + Component, + computed, + ElementRef, + inject, + OnDestroy, + signal, + ViewChild, + ViewContainerRef +} from '@angular/core'; import {ActivatedRoute, Router} from '@angular/router'; import {ActivityService} from '../../services/activity.service'; import {toSignal} from '@angular/core/rxjs-interop'; @@ -9,6 +19,12 @@ import {FormsModule} from '@angular/forms'; import {ModeService} from '../../services/mode.service'; import {TitleComponent} from '../../components/title/title.component'; import {ButtonComponent} from '../../components/button/button.component'; +import * as Blockly from 'blockly'; +import {BlocksModalComponent} from '../../components/blocks-modal/blocks-modal.component'; +import {DescriptionModalComponent} from '../../components/description-modal/description-modal.component'; +import {NgClass} from '@angular/common'; +import {SceneComponent} from '../../components/scene/scene.component'; +import {javascriptGenerator} from 'blockly/javascript'; @Component({ selector: 'blearn-activity-detail', @@ -16,24 +32,43 @@ import {ButtonComponent} from '../../components/button/button.component'; BlocklyEditorComponent, FormsModule, TitleComponent, - ButtonComponent + ButtonComponent, + NgClass, + SceneComponent, ], templateUrl: './activity-detail.component.html', }) -export class ActivityDetailComponent { +export class ActivityDetailComponent implements AfterViewInit, OnDestroy { private route = inject(ActivatedRoute); protected activityService = inject(ActivityService); protected modeService = inject(ModeService); private router = inject(Router); @ViewChild(BlocklyEditorComponent) blocklyEditorComponent!: BlocklyEditorComponent; + @ViewChild(SceneComponent) sceneComponent!: SceneComponent; + @ViewChild('modalHost', {read: ViewContainerRef}) modalHost!: ViewContainerRef; + @ViewChild('scene') scene!: ElementRef; + @ViewChild('canvas') canvas!: ElementRef; + + protected workspace!: Blockly.WorkspaceSvg; + protected toolbox = signal({ + kind: 'flyoutToolbox', + contents: [ + {kind: '', text: '', callbackKey: ''}, + {kind: '', type: ''}, + ] + }); + + protected readonly BLOCK_LIMITS: Map; + + private interpreter: Interpreter | null = null; + protected isRunning = signal(false); private activityId = toSignal( this.route.paramMap.pipe( map(params => params.get('id') || null) ) ); - protected activity = signal(null); constructor() { @@ -54,18 +89,179 @@ export class ActivityDetailComponent { }); this.activity.set(computedActivity()); + this.toolbox.set(JSON.parse(this.activity()!.toolboxInfo.toolboxDefinition)); + this.BLOCK_LIMITS = new Map(Object.entries(this.activity()!.toolboxInfo.BLOCK_LIMITS)); + } + + ngAfterViewInit(): void { + this.workspace = this.blocklyEditorComponent.getWorkspace(); + } + + ngOnDestroy(): void { + this.saveWorkspace(true); + } + + protected openBlocksModal() { + const modalRef = this.modalHost.createComponent(BlocksModalComponent); + modalRef.instance.BLOCKS_LIMIT = this.BLOCK_LIMITS; + + modalRef.instance.blockAdded.subscribe(type => { + if (this.BLOCK_LIMITS.has(type)) this.BLOCK_LIMITS.set(type, this.BLOCK_LIMITS.get(type)! + 1) + else this.BLOCK_LIMITS.set(type, 1); + + if (!this.toolbox().contents.some(block => block.type === type)) { + const newToolbox = { + ...this.toolbox(), + contents: [...this.toolbox().contents, {kind: 'block', type}], + }; + this.toolbox.set(newToolbox); + } + + this.updateToolboxLimits(this.workspace); + }); + + modalRef.instance.blockRemoved.subscribe(type => { + if (this.BLOCK_LIMITS.get(type) === 1) { + this.BLOCK_LIMITS.delete(type); + + if (this.toolbox().contents.some(block => block.type === type)) { + const newToolbox = { + ...this.toolbox(), + contents: this.toolbox().contents.filter(block => block.type !== type) + } + this.toolbox.set(newToolbox); + } + } else if (this.BLOCK_LIMITS.has(type) && this.BLOCK_LIMITS.get(type)! > 1) this.BLOCK_LIMITS.set(type, this.BLOCK_LIMITS.get(type)! - 1); + + this.updateToolboxLimits(this.workspace); + }); + modalRef.instance.close.subscribe(() => { + this.saveWorkspace(false); + modalRef.destroy(); + }); + } + + protected openDescriptionModal() { + const modalRef = this.modalHost.createComponent(DescriptionModalComponent); + modalRef.instance.activity = this.activity; + + modalRef.instance.dueDateUpdated.subscribe(dueDate => this.updateDueDate(dueDate)); + modalRef.instance.descriptionUpdated.subscribe(description => this.updateDescription(description)); + modalRef.instance.close.subscribe(() => modalRef.destroy()); } - updateTitle(newTitle: string) { + protected updateToolboxLimits(workspace: Blockly.WorkspaceSvg) { + const blockCounts = new Map(); + workspace.getAllBlocks(false).forEach(block => { + const type = block.type; + blockCounts.set(type, (blockCounts.get(type) || 0) + 1); + }); + + const newToolbox = { + kind: 'flyoutToolbox', + contents: this.toolbox().contents.map((entry: any) => { + if (entry.kind === 'block') { + const currentCount = blockCounts.get(entry.type) || 0; + const limit = this.BLOCK_LIMITS.get(entry.type); + return { + ...entry, + enabled: limit !== undefined ? currentCount < limit : true, + }; + } else return entry; + }) + } + + workspace.updateToolbox(newToolbox); + } + + protected updateTitle(newTitle: string) { + if (this.activity()) { + this.activity.set({...this.activity()!, title: newTitle}); + this.activityService.updateActivity(this.activityId()!, this.activity()!); + } + } + + protected updateDueDate(newDueDate: string) { + if (this.activity()) { + this.activity.set({...this.activity()!, dueDate: newDueDate}); + this.activityService.updateActivity(this.activityId()!, this.activity()!); + } + } + + protected updateDescription(newDescription: string) { if (this.activity()) { - this.activity.set({ ...this.activity()!, title: newTitle }); + this.activity.set({...this.activity()!, description: newDescription}); this.activityService.updateActivity(this.activityId()!, this.activity()!); } } - saveWorkspace() { - const workspaceJSON = this.blocklyEditorComponent.saveWorkspaceAsJson(); - this.activity.set({ ...this.activity()!, workspace: workspaceJSON }); - this.activityService.updateActivity(this.activityId()!, this.activity()!); + saveWorkspace(onStorage: boolean) { + const jsonWorkspace = Blockly.serialization.workspaces.save(this.workspace); + if (onStorage && this.modeService.getMode() === 'teacher') this.toolbox().contents.shift(); + const jsonToolbox = JSON.stringify(this.toolbox()); + const workspaceJSON = JSON.stringify(jsonWorkspace); + this.activity.set({ + ...this.activity()!, + workspace: workspaceJSON, + toolboxInfo: {BLOCK_LIMITS: Object.fromEntries(this.BLOCK_LIMITS), toolboxDefinition: jsonToolbox} + }); + if (onStorage) this.activityService.updateActivity(this.activityId()!, this.activity()!); + } + + runInterpreter(code: string) { + this.interpreter = new Interpreter(code, this.initApi.bind(this)); + this.stepExecution(); + } + + stepExecution() { + if (!this.interpreter || !this.isRunning()) return; + + const hasMoreCode = this.interpreter.step(); + if (hasMoreCode) { + setTimeout(() => this.stepExecution(), 0.5); + } else { + this.isRunning.set(false); + console.log('Execution finished'); + } + } + + initApi(interpreter: Interpreter, globalObject: any) { + const addFunction = (name: string, fn: Function) => { + interpreter.setProperty( + globalObject, + name, + interpreter.createNativeFunction(fn.bind(this.sceneComponent)) + ); + }; + + addFunction('moveForward', this.sceneComponent.moveForward); + addFunction('moveTo', this.sceneComponent.moveTo); + addFunction('setDirection', this.sceneComponent.setDirection) + addFunction('turnLeft', this.sceneComponent.turnLeft); + addFunction('turnRight', this.sceneComponent.turnRight); + + interpreter.setProperty( + globalObject, + 'waitSeconds', + interpreter.createAsyncFunction((seconds: number, callback: Function) => { + setTimeout(() => callback(), seconds * 1000); + }) + ); + } + + protected onRunCode() { + javascriptGenerator.init(this.workspace); + const startBlock = this.workspace.getTopBlocks(true) + .find(block => block.type === 'event_start'); + + if (!startBlock) { + alert('You should start your program with the "Start program" block!'); + return; + } + const code = javascriptGenerator.blockToCode(startBlock) as string; + console.log(code); + + this.isRunning.set(true); + this.runInterpreter(code); } } diff --git a/src/app/pages/home/home.component.ts b/src/app/pages/home/home.component.ts index 933c34c..0ef30ff 100644 --- a/src/app/pages/home/home.component.ts +++ b/src/app/pages/home/home.component.ts @@ -39,6 +39,10 @@ export class HomeComponent { description: '', dueDate: '', workspace: '{}', + toolboxInfo: { + toolboxDefinition: '{"kind": "flyoutToolbox", "contents": [{ "kind": "", "text": "", "callbackKey": ""}, {"kind": "", "type": ""}]}', + BLOCK_LIMITS: {} + } }; this.activityService.addActivity(newActivity); this.activityList.set(this.activityService.loadActivities()); @@ -88,6 +92,7 @@ export class HomeComponent { description: jsonData.description, dueDate: jsonData.dueDate, workspace: jsonData.workspace, + toolboxInfo: jsonData.toolboxInfo, } } } diff --git a/src/app/services/activity.service.spec.ts b/src/app/services/activity.service.spec.ts index 6ccee44..4cc9845 100644 --- a/src/app/services/activity.service.spec.ts +++ b/src/app/services/activity.service.spec.ts @@ -39,7 +39,11 @@ describe('ActivityService', () => { title: 'Activity 1', description: 'Description', dueDate: '1/1/2000', - workspace: '{}' + workspace: '{}', + toolboxInfo: { + toolboxDefinition: '', + BLOCK_LIMITS: {} + } }; modeService.setMode('student'); @@ -57,7 +61,11 @@ describe('ActivityService', () => { title: 'Activity 1', description: 'Description', dueDate: '1/1/2000', - workspace: '{}' + workspace: '{}', + toolboxInfo: { + toolboxDefinition: '', + BLOCK_LIMITS: {} + } }; modeService.setMode('teacher'); @@ -75,7 +83,11 @@ describe('ActivityService', () => { title: 'Activity 1', description: 'Description', dueDate: '1/1/2000', - workspace: '{}' + workspace: '{}', + toolboxInfo: { + toolboxDefinition: '', + BLOCK_LIMITS: {} + } }]; browserStorageService.loadData.mockReturnValue(mockActivities); @@ -104,14 +116,22 @@ describe('ActivityService', () => { title: 'Activity 2', description: 'Description 2', dueDate: '2/2/2000', - workspace: '{}' + workspace: '{}', + toolboxInfo: { + toolboxDefinition: '', + BLOCK_LIMITS: {} + } }; const existingActivities: Activity[] = [{ id: '1', title: 'Activity 1', description: 'Description', dueDate: '1/1/2000', - workspace: '{}' + workspace: '{}', + toolboxInfo: { + toolboxDefinition: '', + BLOCK_LIMITS: {} + } }]; browserStorageService.loadData.mockReturnValue(existingActivities); @@ -126,14 +146,22 @@ describe('ActivityService', () => { title: 'Activity 1', description: 'Description', dueDate: '1/1/2000', - workspace: '{}' + workspace: '{}', + toolboxInfo: { + toolboxDefinition: '', + BLOCK_LIMITS: {} + } }, { id: '2', title: 'Activity 2', description: 'Description 2', dueDate: '2/2/2000', - workspace: '{}' + workspace: '{}', + toolboxInfo: { + toolboxDefinition: '', + BLOCK_LIMITS: {} + } } ] ); @@ -147,14 +175,22 @@ describe('ActivityService', () => { title: 'Activity 1', description: 'Description', dueDate: '1/1/2000', - workspace: '{}' + workspace: '{}', + toolboxInfo: { + toolboxDefinition: '', + BLOCK_LIMITS: {} + } }, { id: '2', title: 'Activity 2', description: 'Description 2', dueDate: '2/2/2000', - workspace: '{}' + workspace: '{}', + toolboxInfo: { + toolboxDefinition: '', + BLOCK_LIMITS: {} + } } ]; browserStorageService.loadData.mockReturnValue(existingActivities); @@ -169,7 +205,11 @@ describe('ActivityService', () => { title: 'Activity 2', description: 'Description 2', dueDate: '2/2/2000', - workspace: '{}' + workspace: '{}', + toolboxInfo: { + toolboxDefinition: '', + BLOCK_LIMITS: {} + } } ); }); @@ -181,7 +221,11 @@ describe('ActivityService', () => { title: 'Activity 1', description: 'Description', dueDate: '1/1/2000', - workspace: '{}' + workspace: '{}', + toolboxInfo: { + toolboxDefinition: '', + BLOCK_LIMITS: {} + } }; browserStorageService.loadData.mockReturnValue([mockActivity]); @@ -201,7 +245,11 @@ describe('ActivityService', () => { title: 'Activity 1', description: 'Description', dueDate: '1/1/2000', - workspace: '{}' + workspace: '{}', + toolboxInfo: { + toolboxDefinition: '', + BLOCK_LIMITS: {} + } } ]; browserStorageService.loadData.mockImplementation(() => mockActivity); diff --git a/src/app/services/blockly.service.ts b/src/app/services/blockly.service.ts new file mode 100644 index 0000000..1931809 --- /dev/null +++ b/src/app/services/blockly.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@angular/core'; +import * as Blockly from 'blockly'; +import blocks from '../../../assets/blocks.json'; +import {javascriptGenerator} from 'blockly/javascript'; + +@Injectable({ + providedIn: 'root' +}) +export class BlocklyService { + defineCustomBlocks() { + Blockly.defineBlocksWithJsonArray(blocks); + } + + defineCodeGenerationForCustomBlocks() { + javascriptGenerator.forBlock['event_start'] = function () { + return `// Start of the program\n`; + }; + + javascriptGenerator.forBlock['movement_jump'] = function (block: any) { + const x = block.getFieldValue('x'); + const y = block.getFieldValue('y'); + return `moveTo(${x}, ${y});\n`; + }; + + javascriptGenerator.forBlock['movement_forward'] = function(block: any) { + const steps = block.getFieldValue('steps'); + return `moveForward(${steps});\n`; + }; + + javascriptGenerator.forBlock['movement_set_direction'] = function(block: any){ + const angle = block.getFieldValue('angle'); + return `setDirection(${angle});\n`; + }; + + javascriptGenerator.forBlock['movement_turn_left'] = function (block: any) { + const angle = block.getFieldValue('angle'); + return `turnLeft(${angle});\n`; + }; + + javascriptGenerator.forBlock['movement_turn_right'] = function (block: any) { + const angle = block.getFieldValue('angle'); + return `turnRight(${angle});\n`; + }; + + javascriptGenerator.forBlock['controls_wait'] = function(block: any) { + const seconds = block.getFieldValue('seconds'); + return `waitSeconds(${seconds});\n`; + }; + + javascriptGenerator.forBlock['controls_repeat_forever'] = function(block: any) { + const innerCode = javascriptGenerator.statementToCode(block, 'statement'); + return `while (true) {\n${innerCode}}\n`; + } + } +} diff --git a/src/types/js-interpreter.d.ts b/src/types/js-interpreter.d.ts new file mode 100644 index 0000000..040f1ab --- /dev/null +++ b/src/types/js-interpreter.d.ts @@ -0,0 +1,12 @@ +declare class Interpreter { + constructor(code: string, initFunc?: (interpreter: Interpreter, globalObject: any) => void); + + step(): boolean; + setProperty(obj: any, prop: string, value: any): void; + createNativeFunction(fn: Function): any; + createAsyncFunction(fn: Function): any; + + stateStack: any[]; + globalObject: any; +} + diff --git a/tsconfig.app.json b/tsconfig.app.json index 3775b37..3bc9b1d 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -10,6 +10,7 @@ "src/main.ts" ], "include": [ - "src/**/*.d.ts" + "src/**/*.d.ts", + "src/types/**.d.ts" ] }