====== NervDraw: Building a RaphaƫlJS rendering web app ====== {{tag>dev javascript svg}} I desperately need a way to render SVG content quickly to PNG or another raster format to generate a logo. But I couldn't find any free tool online to achieve that. This makes me terribly angry at the world... lol... and at the same time this is giving me enough energy to try to build that missing app myself! ;-). Let's get to work! ====== ====== ===== Starting a minimal AngularJS app ===== * I will call that app **nvdraw** * Creating the app: nv_cmd ng new nvdraw I'm using angular routing and scss scripts in this project * Let's serve the app already: cd nvdraw ng serve --no-open --port 8183 * We add the main page: ng g c page/main * We add the proper routing: const routes: Routes = [ { path: '', component: MainComponent }, ]; ===== Initial RaphaelJS integration ===== * We add a small canvas on the main page:
* And we use some minimal styling to see something: #canvas { background-color: red; width: 300px; height: 200px; } => **OK** Working so far. * Now we add the raphael js dependency in the project: npm install raphael --save * Also installing the types: npm install @types/raphael --save * And now adding the Raphael script in angujar.json: "scripts": [ "node_modules/raphael/raphael.js" ] * And now we can render our Raphel paper already :-) with this: import { Component, OnInit, ElementRef } from '@angular/core'; import { RaphaelPaper } from 'raphael'; declare var Raphael: any; @Component({ selector: 'app-main', templateUrl: './main.component.html', styleUrls: ['./main.component.scss'] }) export class MainComponent implements OnInit { protected paper: RaphaelPaper = null; constructor() { } ngOnInit(): void { // We create the raphaeljs paper on the "canvas" element: this.paper = Raphael("canvas", 640, 480); this.paper.clear(); this.paper.rect(0, 0, 640, 480, 10).attr({fill: "#4f0", stroke: "none"}); } } => This will display a big greenish rectangle just as expected. ===== Display text editing area ===== While searching for an existing component to use as text area I found this amazing package which looks really promising: https://github.com/ngstack/code-editor => let's try to install that to see what it looks like for real: npm install @ngstack/code-editor --save Then I updated my main component to display that editor with the html code:
And the typescript component code export class MainComponent implements OnInit { protected paper: RaphaelPaper = null; theme = 'vs-dark'; codeModel: CodeModel = { language: 'json', uri: 'main.json', value: '{}', }; options = { contextmenu: true, minimap: { enabled: true, }, }; onCodeChanged(value: any) { console.log('CODE', value); } // More stuff here. } And this seems to be working just fine! I have an editor window displayed under my canvas. => Let's just fix the display now: because I would like the editor window next to the canvas area, and both filling the page together. * Let's install jquery already: npm install jquery --save npm install @types/jquery --save * Next we load the jquery script in angular.json: "scripts": [ "node_modules/jquery/dist/jquery.js", "node_modules/raphael/raphael.js" ] => **OK** the display is now correct, and I'm dynamically resizing the canvas with the window on resize event as follow: public updatePaper() { let el = $("#canvas") let ww = el.width(); let hh = el.height(); console.log("canvas size: "+ww+"x"+hh) this.drawPaper(this.paper, ww, hh) } public drawPaper(paper:RaphaelPaper, ww: number, hh: number) { paper.setSize(ww, hh); paper.clear(); paper.rect(0, 0, ww, hh, 10).attr({fill: "#4f0", stroke: "none"}); } ngOnInit(): void { // We create the raphaeljs paper on the "canvas" element: // this.paper = Raphael("canvas", 640, 480); this.paper = Raphael("canvas"); $(window).on('resize', () => { this.updatePaper(); }) this.updatePaper() } ===== Executing user provided code ===== Now it's time to do some code injection from the user provided inputs... The basic idea is to use a script element in the document where we will inject our code dynamically. I would really like to support typescript there but I'm not completely sure I can do that easily enough, let's check this... Or, in fact... maybe it's possible ? (see [[https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API|this page]]) * First I should then move my typescript **dev** dependency as production dependency: npm install typescript --save-prod * Next I try a simple compilation: const source = "let x: string = 'string'"; let result = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS }}); console.log("Compiled javascript: '"+JSON.stringify(result)+"'"); * And this will indeed produce a valid output: Compiled javascript: '{"outputText":"var x = 'string';\r\n","diagnostics":[]}' So it seems we are on a good path! Yet, there is one annoying warning now when building the app package: WARNING in ./node_modules/typescript/lib/typescript.js 5554:41-60 Critical dependency: the request of a dependency is an expression According to [[https://stackoverflow.com/questions/42908116/webpack-critical-dependency-the-request-of-a-dependency-is-an-expression#:~:text=Webpack%20%2D%20Critical%20dependency%3A%20the%20request%20of%20a%20dependency%20is%20an%20expression,-webpack%20request%20ajv&text=When%20a%20library%20uses%20variables,and%20imports%20the%20entire%20package.|this page]], it seems we could be able to fix this issue by changing the version of the request module. Lets' try that: npm remove request npm install request@2.79.0 --save After the installation of the downgraded package we now have a vulnerability apparently: "found 300 vulnerabilities (294 low, 5 moderate, 1 high)"... not sure how critical this could be. And anyway, the build warning is still there! So let's just revert all this mess. Hmmm... and actually using the typescript language in this "monaco editor" is really not as easy as one could expect... But I found another package (ie. rather another integration layer of the monaco editor into angular): https://github.com/atularen/ngx-monaco-editor, so let's give that one a try: npm remove @ngstack/code-editor npm install ngx-monaco-editor --save ...And that is not really better! So let's revert to the previous editor: at least the display was correct with that one. And now I'm starting to get a bit less error since I fixed some stupid syntax mistakes. I use the following code as template: function nvDraw(paper: RaphaelPaper, width: number, height: number) : void { // Add draw code here. console.log("Drawing paper with size "+width+"x"+height); } And with this I only get errors on "RaphaelPaper" which is not known, and "console" too. How can I fix these... (?) => Maybe if I install the monaco editor for offline support ? npm install monaco-editor --save * Adding the asset rule in angular.json: { "glob": "**/*", "input": "../node_modules/monaco-editor/min", "output": "./assets/monaco" }, { "glob": "**/*.js", "input": "../node_modules/@ngstack/code-editor/workers", "output": "./assets/workers" } * And updating the Editor module config: CodeEditorModule.forRoot({ baseUrl: 'assets/monaco', typingsWorkerUrl: 'assets/workers/typings-worker.js' }) => but this is **failing lamentably**... (cannot find the asset files when serving the app from memory without build, not good at all.) And now I'm really desperating to get this working. So let's stop here and just use plain javascript instead. [//Come on Manu...you know you won't accept this lol...//] ... or... in fact, what I really need is to be able to load some typings definition from index.d.ts files as a strings to inject into monaco with the **addExtraLib()** function. => So maybe I could take those raw index.d.ts files and generate the string I need myself manually (with a lua script for instance) ? **Side note**: before I move to the lua script I think I should mention here that initially I was thinking I should dynamically inject a **script** element in the document, so I prepared the following function for this: private getOrCreateScriptElement(ename: string) { console.log("Should retrieve script element: "+ename) var el = $('#'+ename); if(el.length == 0) { console.log("Creating script element "+ename) el = $("").appendTo('body'); } return el; } Then I intended to load some script content by changing the inner html value: this.getOrCreateScriptElement("drawing_code").html(value); But it turns out that this option will not work actually: because when injecting code this way, the script is not **executed** (just "added" as an element to the DOM), what we really need instead is to call **eval()** on the script string, and thus, we do not need to add any element to the DOM. ===== Cleaning typescript declaration files ===== Okay so I wrote a nice lua script to clean the content of the Raphael index.d.ts file and write the result as an exported string: local Class = createClass{name="TSDclGen", bases="app.AppBase"} local path = import 'pl.path' function Class:__init(args) Class.super.__init(self, args) end function Class:setupArgParser() self.argParser:option("-i --input", "Input file to start with") self.argParser:option("-o --output", "Output file to write", "decl_gen.ts") self.argParser:option("-n --varname", "Variable name", "declstr") end function Class:processFileContent(content, declname) -- Convert spaces: content = content:gsub("\r\n", "\n") -- Remove comments: content = content:gsub("//.-\n", "") content = content:gsub("/%*.-%*/", "") -- Reduce number of lines: content = content:gsub("\n+", "\n") -- replace starting/ending newlines: content = content:gsub("^\n", "") content = content:gsub("\n$", "") -- Write content as single string: content = "const "..declname.." = `" .. content .."`;\nexport default "..declname..";" return content end function Class:run() local args = self:getArgs() local ifile = args.input local ofile = args.output local dname = args.varname ifile = path.abspath(path.normpath(ifile)) logDEBUG("Should process input file: ", ifile) local content = nv.readFile(ifile) content = self:processFileContent(content, dname) local file = io.open(ofile, "w") file:write(content) file:close() logDEBUG("Done running app.") end return Class This works just fine and will produce this kind of result: const raphael_dcl = `export type VMLElement = Element; export type VMLCircleElement = VMLElement; export type VMLEllipseElement = VMLElement; export type VMLImageElement = VMLElement; export type VMLPathElement = VMLElement; export type VMLRectElement = VMLElement; export type VMLTextElement = VMLElement; export type RaphaelShapeType = "circle" | "ellipse" | "image" | "rect" | "text" | "path" | "set"; export type RaphaelTechnology = "" | "SVG" | "VML"; export type RaphaelFontOrigin = "baseline" | "middle"; export type RaphaelDashArrayType = "" | "-" | "." | "-." | "-.." | ". " | "- " | "--" | "- ." | "--." | "--.."; export type RaphaelLineCapType = "butt" | "square" | "round"; // (...More stuff here...) declare const R: RaphaelStatic; export default R; declare global { const Raphael: RaphaelStatic; }`; export default raphael_dcl; ... And all this with a very simple command line: nv_seed tsgen -i node_modules/\@types/raphael/index.d.ts -o src/app/typings/raphael.ts -n raphael_dcl Then I load that string and later I try to inject it in the monaco editor: import rdcl from "../../../app/typings/raphael"; monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ // target: monaco.languages.typescript.ScriptTarget.ES2015, target: monaco.languages.typescript.ScriptTarget.ES2016, // target: monaco.languages.typescript.ScriptTarget.ES5, moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs, module: monaco.languages.typescript.ModuleKind.CommonJS, noEmit: true, noLib : false, lib : ['es2018', "dom"], allowNonTsExtensions: true, typeRoots: [ "node_modules/@types" ] }); monaco.languages.typescript.typescriptDefaults.addExtraLib( rdcl, "node_modules/@types/raphael.d.ts" ); But of course... this is once more **just failing on the editor side** with some cryptic error! Grrrr! What a joke... [//Man... I'm really starting to feel desperated here...//] ===== Fixing typescript declaration usage in monaco editor ===== I spent some good time trying to investigate that one without much progress: basically it seemed that whenever I tried to call **monaco.languages.typescript.typescriptDefaults.addExtraLib()** to inject some typescript definition I would then only get an error in the javascript execution: Uncaught Error: Debug Failure. False expression: Paths must either both be absolute or both be relative But then I eventually found [[https://stackoverflow.com/questions/43058191/how-to-use-addextralib-in-monaco-with-an-external-type-definition|this page]]: this looked really similar to the problem I was facing, so I tried to follow the indications there carefully. And it turned out that this was really it! The key seems to be to ensure that we use a **model** to setup the editor, so I use the following: onInit(editor) { // let line = editor.getPosition(); // console.log(line); // console.log("Assigning monaco model.") let model = monaco.editor.createModel(this.code,"typescript", monaco.Uri.parse("a://main.ts")) editor.setModel(model) } This "onInit" function is called on the init of the **ngx-monaco-editor** [=> and yes, I eventually switched again to that other package ;-)] Note the URI in the model above: **%%a://main.ts%%**: "a:%%//%%" doesn't really mean anything here as far as I understand but it is absolutely necessary! And then we also need to ensure that we use the same prefix when defining our extra lib file path: monaco.languages.typescript.typescriptDefaults.addExtraLib( raphael_dcl, 'a://node_modules/@types/raphael/index.d.ts' ); And with these change, we can finaly use typescript with correct code completion and syntax checks, etc... feeeeewwwww! [//This was an hard one! :-)//] ===== Writing canvas to PNG file ===== Before we start considering writing any image file, I should provide a menu bar with action buttons. So I'm going to install bootstrap for that: npm install bootstrap --save Then we load the corresponding styles/scripts in angular.json: "styles": [ "node_modules/bootstrap/dist/css/bootstrap.css", "src/styles.scss" ], "scripts": [ "node_modules/jquery/dist/jquery.js", "node_modules/raphael/raphael.js", "node_modules/bootstrap/dist/js/bootstrap.js" ] And now we can add a menu bar: **OK** Another key element to be able to export raphael content is that we need the **Raphael.Export** plugin available from: https://github.com/AliasIO/Raphael.Export => installing and loading this. **OK** I also need to install canvg: npm install canvg --save And also the Canvas2Image package: npm install canvas2image --save => Actually I don't think I'm going to use Canvas2Image at all: it works but the download name doesn't contain any extension and cannot be changed programmatically: instead we now have the [[https://www.npmjs.com/package/file-saver|FileSaver package]] which seems better: npm install file-saver --save npm install @types/file-saver --save-dev And with these packages, writing our raphael drawing to png is as easy as this: public async exportPNG() { // console.log("Should export PNG here.") let svg = (this.paper as any).toSVG(); const canvas = new OffscreenCanvas(this.width, this.height); const ctx = canvas.getContext('2d'); const v = await Canvg.from(ctx, svg, presets.offscreen()); // Render only first frame, ignoring animations and mouse. await v.render(); const blob = await canvas.convertToBlob(); saveAs(blob, "image.png"); } That's amazing! ===== Adding support for render button ===== Currently we are rendering the canvas each time the editor content is changed. Or at least we are trying to: the typescript code will be converted to javascript and the generated javascript code is evaluated. If there is no exception in the process and the "nvDraw" function is found, then it is called. => But of course, while you are typing code, for most new character that you add the code will not compile and produce an exception... it might even just freeze your web browser window lol [that happened to me multiple times already] So I now think I should rather add a button to perform the compilation/rendering only on user request. Let's handle that. **OK** All good, now we can also disable that render button if the code didn't change since the last successful rendering with: public isCodeRendered() : boolean { return this.renderedCode == this.code } and: ===== Building and serving application ===== Okay, so, this project is not complete yet, but still I think I could now publish a first draft version ;-) So let's build the app now: ng build --prod Now I need to setup a new nginx server config: server { listen 80; server_name draw.nervtech.org; rewrite ^ https://$server_name$request_uri? permanent; } server { listen 443; server_name draw.nervtech.org; # ... Some SSL related stuff here ... client_max_body_size 20m; access_log /var/log/nginx/nvdraw.access.log; error_log /var/log/nginx/nvdraw.error.log; add_header Strict-Transport-Security max-age=15768000; location / { root /mnt/web/nvdraw; try_files $uri $uri/ /index.html; } } And I then copied everything required in the /mnt/web/nvdraw folder. Now I just restart the nginx server... ... And I should also regenerate my SSL certificate to include this new "draw.nervtech.org" domain name, of course: service nginx stop certbot certonly --standalone -d nervtech.org -d api.nervtech.org -d gitlab.nervtech.org -d draw.nervtech.org service nginx start But of course, this doesn't work anymore: and now I remember I received an email from let's encrypt about this a few weeks ago: the protocol used in certbot is not supported anymore, crap! [//Why is everything also so hard to do in the real world ? :-)//] => So I need to find a replacement for that certbot software. **Note**: I currently have certbot 0.10 installed: root@nginxserver:~# certbot --version certbot 0.10.2 => Maybe I can try to upgrade that package ? I'm trying to use [[https://certbot.eff.org/docs/install.html#certbot-auto|certbot-auto]] for this: wget https://dl.eff.org/certbot-auto sudo mv certbot-auto /usr/local/bin/certbot-auto sudo chown root /usr/local/bin/certbot-auto sudo chmod 0755 /usr/local/bin/certbot-auto /usr/local/bin/certbot-auto --help /usr/local/bin/certbot-auto --install-only -v The commands above are not working because I have a problem with my apt-get source list. So following the indications on [[https://debian-facile.org/doc:systeme:apt:sources.list:jessie|this page]] I updated my **/etc/apt/sources.list** with this content now: #deb http://httpredir.debian.org/debian jessie main #deb http://httpredir.debian.org/debian jessie-updates main deb http://deb.debian.org/debian/ jessie main deb http://security.debian.org jessie/updates main deb http://nginx.org/packages/mainline/debian/ jessie nginx #deb http://ftp.debian.org/debian jessie-backports main And with this, the command **apt-get update** will not produce an error: just a missing public key warning. Let's try the certbot-auto again now. => **Cool**, this time certbot installation went fine! Let's check the version we have now: root@nginxserver:~# certbot --version certbot 0.10.2 Hmmm... not quite what I expected :-) but it seems this is now a python app somehow, so let's check how I'm supposed to use it. Ohh, okay: actually you are supposed to use "certbot-auto" directly now: root@nginxserver:~# certbot-auto --version certbot 1.6.0 And it is also finding my current certificate: root@nginxserver:~# certbot-auto certificates Saving debug log to /var/log/letsencrypt/letsencrypt.log - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Found the following certs: Certificate Name: nervtech.org Serial Number: 356c7622f630991677e3c7a501c82d6b584 Domains: nervtech.org api.nervtech.org gitlab.nervtech.org Expiry Date: 2020-08-30 08:02:31+00:00 (VALID: 51 days) Certificate Path: /etc/letsencrypt/live/nervtech.org/fullchain.pem Private Key Path: /etc/letsencrypt/live/nervtech.org/privkey.pem - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Good, let's try regenerating the certificate again now: service nginx stop certbot-auto certonly --standalone -d nervtech.org -d api.nervtech.org -d gitlab.nervtech.org -d draw.nervtech.org service nginx start **Oouufff!** it works!... It failed the first time, but then it worked: that was scary :-). => I should also update my update_certs.sh script to use certbot-auto now: **Done** ===== Conclusion ===== And now **we finally have it ready**! The first version of this free online drawing tool is available at: https://draw.nervtech.org LOL As I said already, there is still a lot that could be added here, but I think I can start with this for now and I will add more features when I need them.