From 478ef3bce7def869de718549764a49d650545f81 Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 19 May 2023 00:35:34 +0200 Subject: [PATCH] front-end code refactoring Since the front-end received much more code (features) than first expected, the files became much too large. For this reason, the JS and CSS code has now been split by component and thus into several small files. However, since there are now many small files, a JS and CSS bundle tool had to come :D. --- .vscode/settings.json | 6 +- docs/contribution/front-end.md | 83 ++ pom.jdk8.xml | 96 +- pom.xml | 128 +- src/main/webapp/assets/github-fork-me.png | Bin 6893 -> 0 bytes src/main/webapp/assets/github-fork-me.svg | 2 + .../app-head.jsp} | 6 +- src/main/webapp/components/app.css | 165 +++ src/main/webapp/components/app.js | 46 + src/main/webapp/components/editor/editor.css | 28 + src/main/webapp/components/editor/editor.js | 112 ++ src/main/webapp/components/editor/editor.jsp | 10 + .../components/editor/menu/editor-menu.css | 92 ++ .../components/editor/menu/editor-menu.js | 15 + .../components/editor/menu/editor-menu.jsp | 35 + .../editor/url-input/editor-url-input.css | 29 + .../editor/url-input/editor-url-input.js | 53 + .../editor/url-input/editor-url-input.jsp | 4 + .../footer}/footer.jsp | 2 +- .../components/header/github-ribbon.jsp | 17 + src/main/webapp/components/header/header.jsp | 8 + .../components/header/social-buttons.jsp | 1 + .../modals/diagram-export/diagram-export.css | 7 + .../modals/diagram-export/diagram-export.js | 97 ++ .../modals/diagram-export}/diagram-export.jsp | 0 .../modals/diagram-import/diagram-import.css | 30 + .../modals/diagram-import/diagram-import.js | 152 +++ .../modals/diagram-import}/diagram-import.jsp | 2 +- src/main/webapp/components/modals/modals.css | 95 ++ src/main/webapp/components/modals/modals.js | 61 + .../components/modals/settings/settings.css | 8 + .../components/modals/settings/settings.js | 54 + .../modals/settings}/settings.jsp | 2 +- .../preview/diagram/preview-diagram.css | 43 + .../preview/diagram/preview-diagram.js | 77 ++ .../preview/diagram/preview-diagram.jsp | 19 + .../components/preview/menu/preview-menu.css | 38 + .../components/preview/menu/preview-menu.jsp | 57 + .../preview/paginator/paginator.css | 8 + .../components/preview/paginator/paginator.js | 62 + .../preview/paginator/paginator.jsp | 1 + .../webapp/components/preview/preview.css | 15 + src/main/webapp/components/preview/preview.js | 42 + .../webapp/components/preview/preview.jsp | 16 + .../preview/social-buttons.jsp} | 0 src/main/webapp/index.jsp | 46 +- src/main/webapp/js/communication/browser.js | 123 ++ src/main/webapp/js/communication/server.js | 20 + src/main/webapp/js/config/config.js | 45 + .../webapp/js/language/completion/emojis.js | 55 + .../webapp/js/language/completion/icons.js | 54 + .../webapp/js/language/completion/themes.js | 58 + .../webapp/js/language/completion/utils.js | 13 + src/main/webapp/js/language/language.js | 92 ++ .../listeners/start-end-validation.js | 103 ++ .../js/language/validation/validation.js | 73 ++ src/main/webapp/js/utilities/dom-helpers.js | 27 + src/main/webapp/js/utilities/os-helpers.js | 8 + src/main/webapp/js/utilities/theme-helpers.js | 39 + src/main/webapp/js/utilities/url-helpers.js | 53 + src/main/webapp/min/plantuml-language.min.js | 45 + src/main/webapp/min/plantuml.min.css | 12 + src/main/webapp/min/plantuml.min.js | 77 ++ src/main/webapp/plantuml.css | 539 --------- src/main/webapp/plantuml.js | 1051 ----------------- src/main/webapp/plantumllanguage.js | 437 ------- src/main/webapp/previewer.jsp | 10 +- src/main/webapp/resource/githubribbon.html | 17 - src/main/webapp/resource/preview.jsp | 89 -- src/main/webapp/resource/socialbuttons1.html | 1 - 70 files changed, 2725 insertions(+), 2186 deletions(-) create mode 100644 docs/contribution/front-end.md delete mode 100644 src/main/webapp/assets/github-fork-me.png create mode 100644 src/main/webapp/assets/github-fork-me.svg rename src/main/webapp/{resource/htmlheadbase.jsp => components/app-head.jsp} (80%) create mode 100644 src/main/webapp/components/app.css create mode 100644 src/main/webapp/components/app.js create mode 100644 src/main/webapp/components/editor/editor.css create mode 100644 src/main/webapp/components/editor/editor.js create mode 100644 src/main/webapp/components/editor/editor.jsp create mode 100644 src/main/webapp/components/editor/menu/editor-menu.css create mode 100644 src/main/webapp/components/editor/menu/editor-menu.js create mode 100644 src/main/webapp/components/editor/menu/editor-menu.jsp create mode 100644 src/main/webapp/components/editor/url-input/editor-url-input.css create mode 100644 src/main/webapp/components/editor/url-input/editor-url-input.js create mode 100644 src/main/webapp/components/editor/url-input/editor-url-input.jsp rename src/main/webapp/{resource => components/footer}/footer.jsp (87%) create mode 100644 src/main/webapp/components/header/github-ribbon.jsp create mode 100644 src/main/webapp/components/header/header.jsp create mode 100644 src/main/webapp/components/header/social-buttons.jsp create mode 100644 src/main/webapp/components/modals/diagram-export/diagram-export.css create mode 100644 src/main/webapp/components/modals/diagram-export/diagram-export.js rename src/main/webapp/{resource => components/modals/diagram-export}/diagram-export.jsp (100%) create mode 100644 src/main/webapp/components/modals/diagram-import/diagram-import.css create mode 100644 src/main/webapp/components/modals/diagram-import/diagram-import.js rename src/main/webapp/{resource => components/modals/diagram-import}/diagram-import.jsp (93%) create mode 100644 src/main/webapp/components/modals/modals.css create mode 100644 src/main/webapp/components/modals/modals.js create mode 100644 src/main/webapp/components/modals/settings/settings.css create mode 100644 src/main/webapp/components/modals/settings/settings.js rename src/main/webapp/{resource => components/modals/settings}/settings.jsp (95%) create mode 100644 src/main/webapp/components/preview/diagram/preview-diagram.css create mode 100644 src/main/webapp/components/preview/diagram/preview-diagram.js create mode 100644 src/main/webapp/components/preview/diagram/preview-diagram.jsp create mode 100644 src/main/webapp/components/preview/menu/preview-menu.css create mode 100644 src/main/webapp/components/preview/menu/preview-menu.jsp create mode 100644 src/main/webapp/components/preview/paginator/paginator.css create mode 100644 src/main/webapp/components/preview/paginator/paginator.js create mode 100644 src/main/webapp/components/preview/paginator/paginator.jsp create mode 100644 src/main/webapp/components/preview/preview.css create mode 100644 src/main/webapp/components/preview/preview.js create mode 100644 src/main/webapp/components/preview/preview.jsp rename src/main/webapp/{resource/socialbuttons2.jsp => components/preview/social-buttons.jsp} (100%) create mode 100644 src/main/webapp/js/communication/browser.js create mode 100644 src/main/webapp/js/communication/server.js create mode 100644 src/main/webapp/js/config/config.js create mode 100644 src/main/webapp/js/language/completion/emojis.js create mode 100644 src/main/webapp/js/language/completion/icons.js create mode 100644 src/main/webapp/js/language/completion/themes.js create mode 100644 src/main/webapp/js/language/completion/utils.js create mode 100644 src/main/webapp/js/language/language.js create mode 100644 src/main/webapp/js/language/validation/listeners/start-end-validation.js create mode 100644 src/main/webapp/js/language/validation/validation.js create mode 100644 src/main/webapp/js/utilities/dom-helpers.js create mode 100644 src/main/webapp/js/utilities/os-helpers.js create mode 100644 src/main/webapp/js/utilities/theme-helpers.js create mode 100644 src/main/webapp/js/utilities/url-helpers.js create mode 100644 src/main/webapp/min/plantuml-language.min.js create mode 100644 src/main/webapp/min/plantuml.min.css create mode 100644 src/main/webapp/min/plantuml.min.js delete mode 100644 src/main/webapp/plantuml.css delete mode 100644 src/main/webapp/plantuml.js delete mode 100644 src/main/webapp/plantumllanguage.js delete mode 100644 src/main/webapp/resource/githubribbon.html delete mode 100644 src/main/webapp/resource/preview.jsp delete mode 100644 src/main/webapp/resource/socialbuttons1.html diff --git a/.vscode/settings.json b/.vscode/settings.json index 7242021..e28984a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,6 +9,7 @@ "epstext", "etag", "ghaction", + "inmemory", "Lalloni", "monaco", "plantuml", @@ -22,5 +23,8 @@ "utxt" ], "cSpell.allowCompoundWords": true, - "svg.preview.background": "transparent" + "svg.preview.background": "dark-transparent", + "files.associations": { + "*.jspf": "html" + } } diff --git a/docs/contribution/front-end.md b/docs/contribution/front-end.md new file mode 100644 index 0000000..cc2bf39 --- /dev/null +++ b/docs/contribution/front-end.md @@ -0,0 +1,83 @@ +# Front-end Contribution + +## Web UI + +The Web UI uses vanilla javascript. + +As online editor Microsoft's [Monaco Editor](https://github.com/microsoft/monaco-editor). +The documentation can be found [here](https://microsoft.github.io/monaco-editor/docs.html). +You may recognize the editor since it's the code editor from [VS Code](https://github.com/microsoft/vscode). + +The main entry file are `index.jsp`, `previewer.jsp` and `error.jsp`. + +The code structure is mainly divided into `components` and `js`: +- `components` are for example a modal or dialog. +Anything that include things directly seen and rendered on the page. +- `js` contains more the things that do not have a direct influence on the UI. For example the PlantUML language features or the methods for cross-browser/cross-tab communication. + + +## PlantUML Language Features + +At the moment there is no defined PlantUML language. +Feel free to create one! +But until then the syntax highlighting form `apex` is used. +IMHO it works quite well. + +All PlantUML language features are bundled into a seperate file `plantuml-language.min.js`. +Therefore anything under `js/language` should be independent! + +### Code Completion +What do you need to do to create a new code completion feature: +1. create a new JS file under `js/language/completion` - let's say `xxx.js` +2. create a new `registerXxxCompletion` method + _It may help you if you look into the [documentation](https://microsoft.github.io/monaco-editor/docs.html#functions/languages.registerCompletionItemProvider.html) or at the provided [sample code](https://microsoft.github.io/monaco-editor/playground.html?source=v0.38.0#example-extending-language-services-completion-provider-example) to understand more about `monaco.languages.registerCompletionItemProvider`._ + ```js + PlantUmlLanguageFeatures.prototype.registerEmojiCompletion = function() { + monaco.languages.registerCompletionItemProvider(PlantUmlLanguageFeatures.languageSelector, { + provideCompletionItems: async (model, position) => { + // ... + return { suggestions }; + } + }); + }; + ``` +4. add your new method inside the language initialization inside `js/language/language.js` + ```diff + const PlantUmlLanguageFeatures = function(initialize = true) { + if (initialize) { + // initialize all validation and code completion methods + this.addStartEndValidationListeners(); + this.registerThemeCompletion(); + this.registerIconCompletion(); + this.registerEmojiCompletion(); + + this.registerXxxCompletion(); + } + }; + ``` + +### Code Validation +What do you need to do to create a new code validation feature: +1. create a new JS file under `js/language/validation/listeners` - let's say `zzz-validation.js` +2. register your validation methods to the designated event listener + The validation event order is: `before` → `code` → `line` → `after` + You may look at `js/language/validation/listeners/start-end-validation.js` to get an idea how to register a new listener. +3. add your new method inside the language initialization inside `js/language/language.js` + ```diff + const PlantUmlLanguageFeatures = function(initialize = true) { + if (initialize) { + // initialize all validation and code completion methods + this.addStartEndValidationListeners(); + + this.addZzzValidationListeners(); + this.registerThemeCompletion(); + this.registerIconCompletion(); + this.registerEmojiCompletion(); + } + }; + ``` + + +### Tipps + +- `pom.xml`: set `withoutCSSJSCompress` to `true` to deactivate the minification +- use `mvn fizzed-watcher:run` to watch changes and automatically update the bundled `plantuml.min.{css,js}` and `plantuml-language.min.js` files +- if the browser get the error `ReferenceError: require is not defined` or something similar related to the webjars, try `mvn clean install` to get things straight diff --git a/pom.jdk8.xml b/pom.jdk8.xml index 8f356fc..5f01450 100644 --- a/pom.jdk8.xml +++ b/pom.jdk8.xml @@ -28,6 +28,10 @@ - mvn test -DskipTests=false -DargLine="-Dsystem.test.server=http://localhost:8080/plantuml" --> true + + false @@ -479,6 +484,95 @@ + + maven-clean-plugin + + + clean-minified-resources + initialize + + clean + + + true + + + ${basedir}/src/main/webapp/min + + + + + + + + org.primefaces.extensions + resources-optimizer-maven-plugin + ${resources-optimizer-maven-plugin.version} + + + optimize + generate-resources + + optimize + + + + + DEFAULT + true + .min + ECMASCRIPT_2020 + ECMASCRIPT5_STRICT + true + + + ${basedir}/src/main/webapp + + components/**/*.js + js/**/*.js + + + js/language/** + + + + ${withoutCSSJSCompress} + false + ${basedir}/src/main/webapp/min/plantuml.min.js + + + + + ${basedir}/src/main/webapp/js/language + + language.js + validation/validation.js + **/*.js + + + + ${withoutCSSJSCompress} + false + ${basedir}/src/main/webapp/min/plantuml-language.min.js + + + + + ${basedir}/src/main/webapp/components + + **/*.css + + + + ${withoutCSSJSCompress} + false + ${basedir}/src/main/webapp/min/plantuml.min.css + + + + + + diff --git a/pom.xml b/pom.xml index e4c716d..e205580 100644 --- a/pom.xml +++ b/pom.xml @@ -28,6 +28,12 @@ - mvn test -DskipTests=false -DargLine="-Dsystem.test.server=http://localhost:8080/plantuml" --> true + + false 2.10 @@ -101,6 +107,8 @@ ${jetty.version} 1.5.1 3.5.0 + 2.5.6 + 1.0.6 @@ -173,7 +181,7 @@ runtime - + org.junit.jupiter junit-jupiter-api @@ -451,6 +459,120 @@ + + maven-clean-plugin + + + clean-minified-resources + initialize + + clean + + + true + + + ${basedir}/src/main/webapp/min + + + + + + + + org.primefaces.extensions + resources-optimizer-maven-plugin + ${resources-optimizer-maven-plugin.version} + + + optimize + generate-resources + + optimize + + + + + DEFAULT + true + .min + ECMASCRIPT_2020 + ECMASCRIPT5_STRICT + true + + + ${basedir}/src/main/webapp + + components/**/*.js + js/**/*.js + + + js/language/** + + + + ${withoutCSSJSCompress} + false + ${basedir}/src/main/webapp/min/plantuml.min.js + + + + + ${basedir}/src/main/webapp/js/language + + language.js + validation/validation.js + **/*.js + + + + ${withoutCSSJSCompress} + false + ${basedir}/src/main/webapp/min/plantuml-language.min.js + + + + + ${basedir}/src/main/webapp/components + + **/*.css + + + + ${withoutCSSJSCompress} + false + ${basedir}/src/main/webapp/min/plantuml.min.css + + + + + + + + com.fizzed + fizzed-watcher-maven-plugin + ${fizzed-watcher-maven-plugin.verson} + + + + ${basedir}/src/main/webapp/components + true + + *.js + *.css + + + *.min.js + *.min.css + + + + + clean:clean@clean-minified-resources + org.primefaces.extensions:resources-optimizer-maven-plugin:optimize + + + diff --git a/src/main/webapp/assets/github-fork-me.png b/src/main/webapp/assets/github-fork-me.png deleted file mode 100644 index 10c08f4d5502ad12ba372eb80812df4bdd557a57..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6893 zcmX9@XIK+m(+(w+P}R^uxzNO{zEDJfMgU70%}fUoaP$#0rR)L;(7q_TO1gxwdO6jgHS$cR`Cgz!3TgTZ966Xx z%rq?dG_^FDwYH@^5W3kZDX`V z%xxf38sE@xaQoP9wy;ON`*%&aSJKVZuN|Vte=*y?cr0i!y06aeKBFg;czi3J4A`BU zK3z#t&&6V|uN^NwVd{!|F!na((f4TTiem%{_1^#IK-SaweqMR|EI@smZu=L;GfJoK z#LzaEaud5J7mf9kw&6+y0s(jGYxUmCZ=%L-ugPn(sbF|(vSzVK;o;0S-NIav!-0ES zl3*AOT|bkE*cnBCopCuwY2WBaUxx~+uSHAWKR@;Hg&n?m%A|&l<<7X4OIlLw@?8aw z(N2%?inRLBeb*j_=ywZYTUGpvkM{?uj_s_9dfu;>xKx6rk)esRKj;?Kg0`npxD>q~ zd(JlpzNlhUKlgPj%L@W^#mzN%ydDh?-zAj2e)Hx{_>j^XnAf+BY*4P-lex7a5lOdH zV@-_>P2^JCtrgZwzoQzwYrVGkg+Wd=E=aqZ#? z;0Xyt?{A-jx0(4#yy`cXZd!bNOsL-57~y?& zc_HpJPX4w}6<%;27O3&?TjY)vV4_efL4YLn@%mnr7?k43y599=c1iPVQ0L!zR&+VT z6Wfnz+<#mqldgfT!bf1#Ec^=&@m&dQaowSDs{4QbntBjS(C5oMOYZRwUI`MV*a zqVbPUS2I9(hdM4P4@K{}^`^6757JT%_ZC`v(A~P?2%sx8_$N{*h@w;|qn!Pxo4k6_ zfSH47@R4U%I4BDuCY)jH5xxG+g-EW~P#7LU-iOQdY zIczLou!w7$x5k%A%7TD&tlsJ4vl4EhZZcLvJ8~DFnC58wJ7g!r;-UTO+8P^A*g9@V z^3(Y;)Vj$yel^=!WM9_R&20|=p>mPknQ27q^#I2W73;XfMC*O?h+i||VSm6{S zwNs&T0-Q&4_JdDNz%ONgo6EhDmiONCn_g4+&mJ=kq8@qgRl^SOL3bfpy5aimiQ}aP zIRc^wf~~LJt4ElO{b&7pc<#feLziON#@s5+Yixl|Szf|H6lqmcyb_42Y_*7HhS+lZ z3TLT_Z21Q-vS@eT&w6hb-@ajtzj6O~cRr8gPNg2+Cq!A_?`<#shQ{8T_fEFPag{#$ zd${G+6%NUQa3sct+%~6?t+Z{mOsk{>f-?XfzrKIo{1_wen$P?ASF~*8c}SP(EvKxf zSdJ*I>X8UL6tV0@IrcMpH`R6(R4(4r?>U8cfP0lt=uwYlN;~CR`Lwd|>dr@=ZItav z=^ZHV4N#W5&WG={_Bza1J5<0}z~}@v$ybk0mf|Y(V|6i`_*7abk8$y{g@|LDF3j4(?!UYa6?NIc{!u`XX5NApV&$DHQYc zBnZW(UntB{v^8UuXv4v~exkTxNy@(OmsqiYyY?GgK17E{|8Fgnhlhu>{c=o^TZOE> z2$`9mU#+ZTCr!y$L$y=qGDMl-M@}e;CY9_dR&37a(?v*iu3E&|l(obE?6sPIrwbh; zo*Vp)Po50^g;F!I)~N>)w0vX}G_6GjDj=6&kH>O@sL87FY2K2ZXNDnay(}~EPnG~A zv;99<_*DI$#O73OktOZL>Dn7}8F7Qj+$)|(1JV%K`Oh35R5mSn#k-o1mKy?8xWmuX zyk>#ONd6JxSPdyE&`NuNN2`5l?`|9BqSx|{Q!x;ZAPNG!^za?3Y6h0szi7LwH0h9* z?jMU6lGH_W1wkFbixFkq zyG2QA&6tM=H!AHW$=dxcPuJe{*o!qS&zzpDB(Weq+uHIS`2T2)G{fH(dls^Lv@^Rl zao7c(Zv0&ci)!?muKQBs+QbrCe_u)8 zJ~Ud-Klbl-J<}5Y;c&j{R%OU=brl;UIn=yGGaYKNyxp?*(NkEioJZ0=OG&kzr_^(@ z+9pp=vUN;pH5CCy5QfVEfgKEr|8>MMnb)h3f%#fCE8dxf$VR(h=!|j4O|5}j6H0;O zJ-UQa-SqC}ljVfCf05k0bkAT<6!9vpj*Jhm(!5pujwT09Kdkn*vYA(+WsWiD;|uD^6!ND=Uv}xWUO$09-JPjQ=KdY?q_znN7Lf> zM2;njaQZVxtWzaJpK7=D*fvmQ;MtA_@~O8 z#YWma{vDgW5I#*`dg4l8*QZRjue7LlEmv$?lwvdZwh+Fj>NeNp$1>EC|M(RULIq%L z!Btjz9Tw}n-lTMLiCcY1`4#Qm^kN`O_Pz(d4KZg%H}e_A6Aw}_ved)cIxYe<_}Beb zt}Vu&M*(iw_=Z%uBa)SIweSwJUi-@2T<%rkN|WFZTGdESy-ZM^@GF%8_!kOe$3$`& ziTkg(2~S~`Ebkq)5zM_Tu3?Dek!T1Fo{EwCze=Z`SFyPoM8T-lwWb`)1dE$EW`3~oIK29xJS!_97V$+y%eJ0Ucx3-28V1ZM(#jAjlSFRwn*sB+jTv>o&CWHiHH{jykxVAN44vB1+r&hI9#4y~~OK{0O+Q$3a$6fJ?UK4>STqJF0tt9B1gsp^qy!zLmY3 z=5rWfx!Sy}Be$Fa6=ch{h0I?Zz?Ln%k^?+JSm}QdvQ5?5RO$OF?KUO)d9Z(i^=(+< zuQEywgH8y~R00*BZKJjF$g&PipEo+4O2ewrGmSMd&E%h;VuRKQoW>tDm$^JYaIKFl zDujNL#N;B8OTRYX)r6IX35h3W;t(rI)^UT@;kZ1CHQf?>| zrSVpD5J9P%pw^RpOKK~#R2q_D%|EEkZ@FphM^-Hr>LtC4b}57OtlX@{kJUJChrt9FMx zDr0PH!1POCpF;W7iRvrkb3rCOmR8bXt8rwJAG$0(J$c!)0 zy^eRLATw7-Ss5djE|fV2IQ z&ljt#um9vc4C+bZtpQ!wvj8wBM-Sw|boBUZQSD>J3JH37JHAKP4qRWNDs@c{ZjN6Y zb0Q>Kt`Ok;X7bFSjd_h}dj7c8qZp}+mKTjZvI8;se%7WPTA6{3rq)YC z_FJ~oUUMva@)IG0GDwCK^nrJB{O5_%3)xMOvfF``IE{?x(H!M?#f6zZ1M7eU< zk4+M=I$mC{kJhNMZS@o~cZVVGwprFXYQ7``+gyjUdx zG>c+lEppDf_3*d);sn|u_kEl;_MJX z>zWDb7O3h7*(k{v`y+3~Y8C?la_W)B z%gT3zPIrmfGt15OqZU+Rg&Qjx%5ghJBkn&K$k^#50B=HU!j|lboFP%dgm!0dKKl+k z5F7_St96gA)Z@2nVkODIiAFbI2bb47VjZ0|IUVIv981Pxvwa?aNH~rt@On9+n2TC*UMaO-&2Ay+OxQwCxX2 zB5j3B&?r#|q=F`tW@pyB#UNJoy(%t=f}j_m=t-rc>l)m0ZA9B=)&`wi?| zK|TCkxhISmygq_sPJtT0&U1qtc!R3$E2dmJxFIb$TS<)vkTKS+IOD08JoQPY4!QnZ z0F)A?)bjug@1@_Ps}CUclxY8=)5}c<&~iY2@KBzL`Ki$qg*&w7<{mjFN$pHKnzELu z0Dr@sUZAaMyRIKxz@7@BP@C?;RIRU5(x`M>T{8%yO{RVba)crKm-Fu2v`;DJi)>pi zkclSSAJNui-yB02A-CJ3wR^sizcqd5V#!`xNY8%*uu?;)Nijw+b6t8)d;7Y1Xf>^v zHgZV5yer~NrQ|s#GS0(s&FD+Z$}5#Ir>|Cy{)H_kixf3{^oH{ zfy<6Wts1wf=qi{2{o~RVBTVUh4WIwBO`|{4A7U^7nHAz_Lgx%ah!;n*<6J3M{3k%; zvt{#76;uSn>g4VRrt6AyY{D6hBlr3TAB|URG<` zs$57R+jn4NW*p?OujIz%i#f|IZHoE7RB8=Bt{$x%kwHFZ*UQGV(Pv_%(#`*q;|ZDW z&dB2DPwL^H`rTzOF2{~!l1h)NQ*Md=J?H+ZLIGH-&mz!m`N7*pdWJeh-z3j`kE;GO zd%I5AvGX~IAr=OST2@fL`6zStYgPpJ`4@7*bi=0s{y{LG>QRrk7usV<-57{w+aFI5 zhV>75x|(e2_-G;SR|EbU036ARWW>`OwoJ3f{QNNA5}fovvC|zi`UPMf!F37e1RXL^ zj#!QI>M_MB?|;{+)#c#nDttjF(WDU{iB5>#(b!G4;1AdAP}?>m8^JT*j5siHvFNnq z8BAuNEVMcvl_yAXf<9XkwZW#dJM0fg&ouD{wvtUr> zkn$FEwTU8IWsit300`aJ&eUwnVZBHEB1C{wanS&v!OsXu_-Y{XOY~};k@QV)P;|qs z7hPy678YEwmAh3Zf0TgSrYH}UupDw{r<%#bi84W8fkfj~CV-jb>EmV|g|#9Fg7P

os+m<8wB|BFG%0_DlNxb zA02$&PvP#V_D!y06B8z{QwQC?A;l6W_XXi_h7vyOV$lFQ#7Kl zU>?X!>Qj&qiXZRZV}N_(r656qhA83hoNA9lN}lmHNEY5)c!2n=X=V1c`wIs0l0(l2 z1Q$7&(P*P&r!y91EDopdb^J8c2RqBRFfNhC7mL!27fGcrfIFG2e=1Fg(|&axSGr;~ zTa#VBpje?w<4;ZZWXF$XKYChEr_-9og<`Q%(!wL|mCc z8TwX+5=|;46AE6__=S~N>^wuskDJx-cNrb96lw`DhEU*!#};`Qmhb?`;IN;OE2H1@l3vl z9@47DTYd0Ov-Gkuwy+0o?n}%bZW_R#Z?|b0G8k150BD80sF5M7lZCOxZ`*;oVAMQb zpc~Wos(6Zl6gy{-sIC%V8c#Hbo&wi>V(`YOaB>n!p=e~eC=Hcp;4ZAQ1(*gz3EQWo zr&pBn2^Bb0YWl~rnz5qu4Pfhx9bU%S7&K`;XriPo@jn^2A?}hY_g;f~SvrUcxokT4 z>@x~#nZ`i5%#ic>%Tz3u(8>GVH!ZMwrAFSW-y zxen)7aYiOjNJsy>okruN`Z4n2%*_Fcf(4VxYSU@bEI?G@Av&oVUAw$-y1(|Wlc8+ti#G@P4 zeTSP1sI)6!tA0S@O;u%9r{1C6vq1T0j67iod*kVp>8~RcXyxR(hTi7SPtAZ0-k6~S{;Sg-!u8X#y7Amdd1 zpA(|{bA>I9r%hz}>R&bU=ix@4S)f Stw#EP1<=tl)U1ZvWBv~ks^vBS diff --git a/src/main/webapp/assets/github-fork-me.svg b/src/main/webapp/assets/github-fork-me.svg new file mode 100644 index 0000000..b4a5be9 --- /dev/null +++ b/src/main/webapp/assets/github-fork-me.svg @@ -0,0 +1,2 @@ + + diff --git a/src/main/webapp/resource/htmlheadbase.jsp b/src/main/webapp/components/app-head.jsp similarity index 80% rename from src/main/webapp/resource/htmlheadbase.jsp rename to src/main/webapp/components/app-head.jsp index 23ed6bd..a60534e 100644 --- a/src/main/webapp/resource/htmlheadbase.jsp +++ b/src/main/webapp/components/app-head.jsp @@ -8,7 +8,7 @@ - + + + - - diff --git a/src/main/webapp/components/app.css b/src/main/webapp/components/app.css new file mode 100644 index 0000000..c3838ce --- /dev/null +++ b/src/main/webapp/components/app.css @@ -0,0 +1,165 @@ +/********************************** +* PlantUML Server Application CSS * +***********************************/ + +/************* variables *************/ +:root { + color-scheme: light dark; + --font-color: black; + --font-color-disabled: #888; + --bg-color: white; + --border-color: #ccc; + --border-color-2: #aaa; + --footer-font-color: #666; + --footer-bg-color: #eee; + --modal-bg-color: #fefefe; + --file-drop-color: #eee; +} +[data-theme="dark"] { + --font-color: #ccc; + --font-color-disabled: #777; + --bg-color: #212121; + --border-color: #848484; + --border-color-2: #aaa; + --footer-font-color: #ccc; + --footer-bg-color: black; + --modal-bg-color: #424242; + --file-drop-color: #212121; +} + +/************* default settings *************/ +html, body { + margin: 0; + padding: 0; +} +html { + font-family: arial,helvetica,sans-serif; +} +body { + background-color: var(--bg-color); + color: var(--font-color); + overflow: auto; +} +@media screen and (min-width: 900px) { + body { + height: 100vh; + overflow: hidden; + } + .app { + height: 100%; + } +} +input:not([type="image"]) { + background-color: var(--bg-color); + color: var(--font-color); +} +input[type="file"]::file-selector-button { + background-color: var(--bg-color); + color: var(--font-color); +} +select { + background-color: var(--bg-color); + color: var(--font-color); +} + +/************* ruler *************/ +.hr { + padding: 1rem 0; + width: 100%; +} +.flex-columns > .hr { + padding: 0 1rem; + width: initial; + height: 100%; +} +.hr:after { + content: ""; + display: block; + background-color: var(--border-color); + height: 100%; + width: 100%; + min-height: 3px; + min-width: 3px; +} + +/************* wait cursor *************/ +.wait { + cursor: wait; +} +.wait > * { + pointer-events: none; +} + +/************* flex rows and columns *************/ +.flex-columns { + display: flex; + flex-direction: row; + flex-wrap: wrap; +} +.flex-rows { + display: flex; + flex-direction: column; +} +.flex-main { + flex: 1 1 1px; + overflow: auto; +} +.flex-columns > *, .flex-rows > * { + flex-shrink: 0; +} + +/*******************************************************************/ +/************* header, main, footer *************/ +.header { + margin-left: auto; + margin-right: auto; + text-align: center; +} +.main { + margin: 1% 5%; + z-index: 1; +} +.main > div { + margin: 0 1.75%; +} +.main > div:first-child { + margin-left: 0; +} +.main > div:last-child { + margin-right: 0; +} +@media screen and (max-width: 900px) { + .main { + display: block; + overflow: inherit; + } + .main > div { + margin: 1.75% 0; + } + .main > div:first-child { + margin-top: 0; + } + .main > div:last-child { + margin-bottom: 0; + } +} +.footer p { + background-color: var(--footer-bg-color); + color: var(--footer-font-color); + font-size: 0.7em; + margin: 0; + padding: 0.5em; + text-align: center; +} + +/*******************************************************************/ +/************* color themes *************/ +[data-theme="dark"] img:not(#diagram-png):not(.no-filter) { + filter: invert() contrast(30%); +} +[data-theme="dark"] input[type="image"] { + filter: invert() contrast(30%); +} +[data-theme="dark"] a { + color: white; +} diff --git a/src/main/webapp/components/app.js b/src/main/webapp/components/app.js new file mode 100644 index 0000000..9079adf --- /dev/null +++ b/src/main/webapp/components/app.js @@ -0,0 +1,46 @@ +/********************************* +* PlantUML Server Application JS * +**********************************/ +"use strict"; + +async function initApp() { + const view = new URL(window.location.href).searchParams.get("view")?.toLowerCase(); + + function initializeAppData() { + const analysedUrl = analyseUrl(window.location.href); + const code = document.editor?.getValue(); + document.appData = Object.assign({}, window.opener?.document.appData); + if (Object.keys(document.appData).length === 0) { + document.appData = { + encodedDiagram: analysedUrl.encodedDiagram, + index: analysedUrl.index, + numberOfDiagramPages: (code) ? getNumberOfDiagramPagesFromCode(code) : 1, + }; + } + } + + await initEditor(view); + initializeAppData(); + initTheme(); + initAppCommunication(); + await initPreview(view); + initModals(view); + + if (document.editor) { + document.editor.focus(); + if (document.appData.encodedDiagram == "SyfFKj2rKt3CoKnELR1Io4ZDoSa70000") { + // if default `Bob -> Alice : hello` example mark example code for faster editing + document.editor.setSelection({ + startLineNumber: 2, + endLineNumber: 2, + startColumn: 1, + endColumn: 21, + }); + } + } + + document.appConfig.autoRefreshState = "complete"; +} + +// main entry +window.onload = initApp; diff --git a/src/main/webapp/components/editor/editor.css b/src/main/webapp/components/editor/editor.css new file mode 100644 index 0000000..5e78737 --- /dev/null +++ b/src/main/webapp/components/editor/editor.css @@ -0,0 +1,28 @@ +/************* +* Editor CSS * +**************/ + +.editor { + border: 3px solid var(--border-color); + box-sizing: border-box; + overflow: hidden; +} +@media screen and (max-width: 900px) { + .editor { + height: 20em; + } +} +.editor .monaco-editor-container { + overflow: hidden; + position: relative; +} + +#monaco-editor { + height: 100%; +} +/* Hack to display the icons and emojis in the auto completion documentation in a visible size. + * (see PlantUmlLanguageFeatures.register{Icon,Emoji}Completion) */ +#monaco-editor .overlayWidgets .suggest-details p img[alt="icon"], +#monaco-editor .overlayWidgets .suggest-details p img[alt="emoji"] { + height: 1.2rem; +} diff --git a/src/main/webapp/components/editor/editor.js b/src/main/webapp/components/editor/editor.js new file mode 100644 index 0000000..06125d2 --- /dev/null +++ b/src/main/webapp/components/editor/editor.js @@ -0,0 +1,112 @@ +/************ +* Editor JS * +*************/ + +const { setEditorValue, initEditor } = (function() { + function setEditorValue( + editor, + text, + { suppressEditorChangedMessage=false, forceMoveMarkers=undefined } = {} + ) { + if (suppressEditorChangedMessage && editor === document.editor) { + suppressNextMessage("editor"); + } + // replace editor value but preserve undo stack + editor.executeEdits("", [{ range: editor.getModel().getFullModelRange(), text, forceMoveMarkers }]); + } + + async function initEditor(view) { + function loadMonacoCodeEditorAsync() { + return new Promise((resolve, _reject) => { + require.config({ paths: { vs: "webjars/monaco-editor/0.36.1/min/vs" } }); + require(["vs/editor/editor.main"], resolve); + }); + } + function createEditorModel() { + let plantumlFeatures; + function onPlantumlEditorContentChanged(code, sender=undefined, broadcastChanges=true) { + function broadcastCodeEditorChanges() { + document.appConfig.autoRefreshState = "started"; + const numberOfDiagramPages = getNumberOfDiagramPagesFromCode(code); + let index = document.appData.index; + if (index === undefined || numberOfDiagramPages === 1) { + index = undefined; + } else if (index >= numberOfDiagramPages) { + index = numberOfDiagramPages - 1; + } + makeRequest("POST", "coder", { data: code }).then((encodedDiagram) => { + sendMessage({ + sender, + data: { encodedDiagram, numberOfDiagramPages, index }, + synchronize: true, + }); + }); + } + const updatePlantumlLanguageMarkers = (function() { + return function() { + const model = document.editor.getModel(); + plantumlFeatures = plantumlFeatures || new PlantUmlLanguageFeatures(); + plantumlFeatures.validateCode(model) + .then(markers => monaco.editor.setModelMarkers(model, "plantuml", markers)); + } + })(); + if (sender && broadcastChanges) broadcastCodeEditorChanges(); + updatePlantumlLanguageMarkers(); + } + function getInitPlantumlCodeAndRemoveElement() { + const initCodeEl = document.getElementById("initCode"); + const initCode = initCodeEl.value; + initCodeEl.remove(); + return initCode; + } + // create editor model + const model = monaco.editor.createModel( + getInitPlantumlCodeAndRemoveElement(), + "apex", + monaco.Uri.parse("inmemory://plantuml") + ); + // create editor model watcher + let timer = 0; + model.onDidChangeContent(() => { + clearTimeout(timer); + document.appConfig.autoRefreshState = "waiting"; + timer = setTimeout( + () => onPlantumlEditorContentChanged(model.getValue(), "editor"), + document.appConfig.editorWatcherTimeout + ); + }); + return model; + } + function getDefaultStorageService() { + // create own storage service to expand suggestion documentation by default + return { + get() {}, + getBoolean(key) { return key === "expandSuggestionDocs"; }, + getNumber() { return 0; }, + remove() {}, + store() {}, + onWillSaveState() {}, + onDidChangeStorage() {}, + onDidChangeValue() {}, + }; + } + + // load monaco editor requirements + await loadMonacoCodeEditorAsync(); + if (view !== "previewer") { + // create editor + const model = createEditorModel(); + const storageService = getDefaultStorageService(); + document.editor = monaco.editor.create(document.getElementById("monaco-editor"), { + model, ...document.appConfig.editorCreateOptions + }, { storageService }); + // sometimes the monaco editor has resize problems + document.addEventListener("resize", () => document.editor.layout()); + // init editor components + initEditorUrlInput(); + initEditorMenu(); + } + } + + return { setEditorValue, initEditor }; +})(); diff --git a/src/main/webapp/components/editor/editor.jsp b/src/main/webapp/components/editor/editor.jsp new file mode 100644 index 0000000..7e34bde --- /dev/null +++ b/src/main/webapp/components/editor/editor.jsp @@ -0,0 +1,10 @@ +

+
+ <%@ include file="/components/editor/url-input/editor-url-input.jsp" %> +
+
+ +
+ <%@ include file="/components/editor/menu/editor-menu.jsp" %> +
+
diff --git a/src/main/webapp/components/editor/menu/editor-menu.css b/src/main/webapp/components/editor/menu/editor-menu.css new file mode 100644 index 0000000..cdd4bb3 --- /dev/null +++ b/src/main/webapp/components/editor/menu/editor-menu.css @@ -0,0 +1,92 @@ +/****************** +* Editor Menu CSS * +*******************/ + +.monaco-editor-container .editor-menu { + position: absolute; + right: 0; + top: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + flex: 1; +} +.monaco-editor-container .editor-menu > div.menu-kebab { + width: 60px; + height: 60px; + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + cursor: pointer; + scale: 0.5; +} +.monaco-editor-container .editor-menu:hover > div.menu-kebab, +.monaco-editor-container .editor-menu:focus > div.menu-kebab { + outline: none; + scale: 0.65; +} +.monaco-editor-container .menu-kebab .kebab-circle { + width: 12px; + height: 12px; + margin: 3px; + background: var(--font-color); + border-radius: 50%; + display: block; + opacity: 0.8; +} +.monaco-editor-container .menu-kebab { + flex-direction: column; + position: relative; + transition: all 300ms cubic-bezier(0.175, 0.885, 0.32, 1.275); +} +.monaco-editor-container .menu-kebab .kebab-circle:nth-child(4), +.monaco-editor-container .menu-kebab .kebab-circle:nth-child(5) { + position: absolute; + opacity: 0; + top: 50%; + margin-top: -6px; + left: 50%; +} +.monaco-editor-container .menu-kebab .kebab-circle:nth-child(4) { + margin-left: -25px; +} +.monaco-editor-container .menu-kebab .kebab-circle:nth-child(5) { + margin-left: 13px; +} +.monaco-editor-container .editor-menu:hover .menu-kebab, +.monaco-editor-container .editor-menu:focus .menu-kebab { + transform: rotate(45deg); +} +.monaco-editor-container .editor-menu:hover .menu-kebab .kebab-circle, +.monaco-editor-container .editor-menu:focus .menu-kebab .kebab-circle { + opacity: 1; +} + +.monaco-editor-container .editor-menu .menu-item { + display: none; + margin: 1rem 0; + height: 1.75rem; + opacity: 0.5; + position: relative; + -webkit-animation-name: editor-menu-animateitem; + -webkit-animation-duration: 0.4s; + animation-name: editor-menu-animateitem; + animation-duration: 0.4s; +} +@-webkit-keyframes editor-menu-animateitem { + from { top: -50%; opacity: 0; } + to { top: 0; opacity: 0.5; } +} +@keyframes editor-menu-animateitem { + from { top: -50%; opacity: 0; } + to { top: 0; opacity: 0.5; } +} +.monaco-editor-container .editor-menu .menu-item:hover { + opacity: 1; +} +.monaco-editor-container .editor-menu:hover .menu-item, +.monaco-editor-container .editor-menu:focus .menu-item { + display: block; +} diff --git a/src/main/webapp/components/editor/menu/editor-menu.js b/src/main/webapp/components/editor/menu/editor-menu.js new file mode 100644 index 0000000..cc633e1 --- /dev/null +++ b/src/main/webapp/components/editor/menu/editor-menu.js @@ -0,0 +1,15 @@ +/***************** +* Editor Menu JS * +******************/ + +function initEditorMenu() { + function copyCodeToClipboard() { + const range = document.editor.getModel().getFullModelRange(); + document.editor.focus(); + document.editor.setSelection(range); + const code = document.editor.getValue(); + navigator.clipboard?.writeText(code).catch(() => {}); + } + // add listener + document.getElementById("menu-item-editor-code-copy").addEventListener("click", copyCodeToClipboard); +} diff --git a/src/main/webapp/components/editor/menu/editor-menu.jsp b/src/main/webapp/components/editor/menu/editor-menu.jsp new file mode 100644 index 0000000..c80ca28 --- /dev/null +++ b/src/main/webapp/components/editor/menu/editor-menu.jsp @@ -0,0 +1,35 @@ +
+ + +
diff --git a/src/main/webapp/components/editor/url-input/editor-url-input.css b/src/main/webapp/components/editor/url-input/editor-url-input.css new file mode 100644 index 0000000..440c986 --- /dev/null +++ b/src/main/webapp/components/editor/url-input/editor-url-input.css @@ -0,0 +1,29 @@ +/*********************** +* Editor URL Input CSS * +************************/ + +.editor .btn-input { + align-items: center; + border-bottom: 3px solid var(--border-color); + box-sizing: border-box; + display: flex; + justify-content: center; +} +.editor .btn-input input[type=text] { + border: 0; + flex: 1 1 1px; + font-family: monospace; + font-size: medium; + padding: 0.2em; + text-overflow: ellipsis; +} +.editor .btn-input input[type=text]:focus { + border: 0; + box-shadow: none; + outline: none; +} +.editor .btn-input input[type="image"] { + height: 1rem; + margin-left: 0.7em; + padding: 0 0.3em; +} diff --git a/src/main/webapp/components/editor/url-input/editor-url-input.js b/src/main/webapp/components/editor/url-input/editor-url-input.js new file mode 100644 index 0000000..6973ecc --- /dev/null +++ b/src/main/webapp/components/editor/url-input/editor-url-input.js @@ -0,0 +1,53 @@ +/********************** +* Editor URL Input JS * +***********************/ + +const { setUrlValue, initEditorUrlInput } = (function() { + function setUrlValue( + url=undefined, + { encodedDiagram=undefined, index=undefined } = {}, + { suppressEditorChangedMessage=false } = {} + ) { + if (!url && !encodedDiagram) return; + if (suppressEditorChangedMessage) { + suppressNextMessage("url"); + } + document.getElementById("url").value = url ? url : resolvePath(buildUrl("png", encodedDiagram, index)); + } + + function initEditorUrlInput() { + const input = document.getElementById("url"); + + function copyUrlToClipboard() { + input.focus(); + input.select(); + navigator.clipboard?.writeText(input.value).catch(() => {}); + } + async function onInputChanged(event) { + document.appConfig.autoRefreshState = "started"; + event.target.title = event.target.value; + const analysedUrl = analyseUrl(event.target.value); + // decode diagram (server request) + const code = await makeRequest("GET", "coder/" + analysedUrl.encodedDiagram); + // change editor content without sending the editor change message + setEditorValue(document.editor, code, { suppressEditorChangedMessage: true }); + sendMessage({ + sender: "url", + data: { + encodedDiagram: analysedUrl.encodedDiagram, + index: analysedUrl.index, + }, + synchronize: true, + }); + } + + // resolve relative path inside url input once + setUrlValue(resolvePath(input.value)); + // update editor and everything else if the URL input is changed + input.addEventListener("change", onInputChanged); + // add listener + document.getElementById("url-copy-btn").addEventListener("click", copyUrlToClipboard); + } + + return { setUrlValue, initEditorUrlInput }; +})(); diff --git a/src/main/webapp/components/editor/url-input/editor-url-input.jsp b/src/main/webapp/components/editor/url-input/editor-url-input.jsp new file mode 100644 index 0000000..f3d765b --- /dev/null +++ b/src/main/webapp/components/editor/url-input/editor-url-input.jsp @@ -0,0 +1,4 @@ +
+ + +
diff --git a/src/main/webapp/resource/footer.jsp b/src/main/webapp/components/footer/footer.jsp similarity index 87% rename from src/main/webapp/resource/footer.jsp rename to src/main/webapp/components/footer/footer.jsp index a734415..9145ddf 100644 --- a/src/main/webapp/resource/footer.jsp +++ b/src/main/webapp/components/footer/footer.jsp @@ -1 +1 @@ -

<%= net.sourceforge.plantuml.version.Version.fullDescription() %>

\ No newline at end of file +

<%= net.sourceforge.plantuml.version.Version.fullDescription() %>

diff --git a/src/main/webapp/components/header/github-ribbon.jsp b/src/main/webapp/components/header/github-ribbon.jsp new file mode 100644 index 0000000..1baa857 --- /dev/null +++ b/src/main/webapp/components/header/github-ribbon.jsp @@ -0,0 +1,17 @@ +
+ Fork me on GitHub + + Fork me on GitHub + +
diff --git a/src/main/webapp/components/header/header.jsp b/src/main/webapp/components/header/header.jsp new file mode 100644 index 0000000..87eb9fb --- /dev/null +++ b/src/main/webapp/components/header/header.jsp @@ -0,0 +1,8 @@ +

PlantUML Server

+<% if (showSocialButtons) { %> + <%@ include file="/components/header/social-buttons.jsp" %> +<% } %> +<% if (showGithubRibbon) { %> + <%@ include file="/components/header/github-ribbon.jsp" %> +<% } %> +

Create your PlantUML diagrams directly in your browser!

diff --git a/src/main/webapp/components/header/social-buttons.jsp b/src/main/webapp/components/header/social-buttons.jsp new file mode 100644 index 0000000..e7e90eb --- /dev/null +++ b/src/main/webapp/components/header/social-buttons.jsp @@ -0,0 +1 @@ + diff --git a/src/main/webapp/components/modals/diagram-export/diagram-export.css b/src/main/webapp/components/modals/diagram-export/diagram-export.css new file mode 100644 index 0000000..0c3a8ba --- /dev/null +++ b/src/main/webapp/components/modals/diagram-export/diagram-export.css @@ -0,0 +1,7 @@ +/********************* +* Diagram Export CSS * +**********************/ + +#diagram-export.modal .label-input-pair label { + min-width: 8rem; +} diff --git a/src/main/webapp/components/modals/diagram-export/diagram-export.js b/src/main/webapp/components/modals/diagram-export/diagram-export.js new file mode 100644 index 0000000..c2428ae --- /dev/null +++ b/src/main/webapp/components/modals/diagram-export/diagram-export.js @@ -0,0 +1,97 @@ +/******************** +* Diagram Export JS * +*********************/ + +function initDiagramExport() { + const filenameInput = document.getElementById("download-name"); + const fileTypeSelect = document.getElementById("download-type"); + + function openDiagramExportDialog() { + setVisibility(document.getElementById("diagram-export"), true, true); + const code = document.editor.getValue(); + const name = Array.from( + code.matchAll(/^\s*@start[a-zA-Z]*\s+([a-zA-Z-_äöüÄÖÜß ]+)\s*$/gm), + m => m[1] + )[0] || "diagram"; + filenameInput.value = name + ".puml"; + fileTypeSelect.value = "code"; + filenameInput.focus(); + } + function splitFilename(filename) { + const idx = filename.lastIndexOf("."); + if (idx < 1) { + return { name: filename, ext: null }; + } + if (idx === filename.length - 1) { + return { name: filename.slice(0, -1), ext: null }; + } + return { + name: filename.substring(0, idx), + ext: filename.substring(idx + 1), + }; + } + function getExtensionByType(type) { + switch (type) { + case "epstext": return "eps"; + case "code": return "puml"; + default: return type; + } + } + function getTypeByExtension(ext) { + if (!ext) return ext; + ext = ext.toLowerCase(); + switch (ext) { + case "puml": + case "plantuml": + case "code": + return "code"; + case "ascii": return "txt" + default: return ext; + } + } + function onTypeChanged(event) { + const type = event.target.value; + const ext = getExtensionByType(type); + const { name } = splitFilename(filenameInput.value); + filenameInput.value = name + "." + ext; + } + function onFilenameChanged(event) { + const { ext } = splitFilename(event.target.value); + const type = getTypeByExtension(ext); + if (!type) return; + fileTypeSelect.value = type; + } + function downloadFile() { + const filename = filenameInput.value; + const type = fileTypeSelect.value; + const link = document.createElement("a"); + link.download = filename; + if (type === "code") { + const code = document.editor.getValue(); + link.href = "data:," + encodeURIComponent(code); + } else { + if (document.appData.index !== undefined) { + link.href = type + "/" + document.appData.index + "/" + document.appData.encodedDiagram; + } else { + link.href = type + "/" + document.appData.encodedDiagram; + } + } + link.click(); + } + + // register modal + registerModalListener("diagram-export", openDiagramExportDialog); + // add listener + filenameInput.addEventListener("change", onFilenameChanged); + fileTypeSelect.addEventListener("change", onTypeChanged); + document.getElementById("diagram-export-ok-btn").addEventListener("click", downloadFile); + // add Ctrl+S or Meta+S (Mac) key shortcut to open export dialog + window.addEventListener("keydown", event => { + if (event.key === "s" && (isMac ? event.metaKey : event.ctrlKey)) { + event.preventDefault(); + if (!isModalOpen("diagram-export")) { + openDiagramExportDialog(); + } + } + }, false); +} diff --git a/src/main/webapp/resource/diagram-export.jsp b/src/main/webapp/components/modals/diagram-export/diagram-export.jsp similarity index 100% rename from src/main/webapp/resource/diagram-export.jsp rename to src/main/webapp/components/modals/diagram-export/diagram-export.jsp diff --git a/src/main/webapp/components/modals/diagram-import/diagram-import.css b/src/main/webapp/components/modals/diagram-import/diagram-import.css new file mode 100644 index 0000000..189d3c1 --- /dev/null +++ b/src/main/webapp/components/modals/diagram-import/diagram-import.css @@ -0,0 +1,30 @@ +/********************* +* Diagram Import CSS * +**********************/ + +#diagram-import p.error-message { + color: darkred; + padding-left: 1rem; + padding-right: 1rem; +} +#diagram-import input[type="file"] { + display: block; + width: 100%; + border: 0.2rem dashed var(--border-color); + border-radius: 0.4rem; + box-sizing: border-box; + padding: 5rem 2rem; +} +#diagram-import input[type="file"], +#diagram-import input[type="file"]::file-selector-button { + background-color: var(--modal-bg-color); +} +#diagram-import input[type="file"]:hover, +#diagram-import input[type="file"].drop-able { + border-color: var(--border-color-2); + background-color: var(--file-drop-color); +} +#diagram-import input[type="file"]:hover::file-selector-button, +#diagram-import input[type="file"].drop-able::file-selector-button { + background-color: var(--file-drop-color); +} diff --git a/src/main/webapp/components/modals/diagram-import/diagram-import.js b/src/main/webapp/components/modals/diagram-import/diagram-import.js new file mode 100644 index 0000000..43e5540 --- /dev/null +++ b/src/main/webapp/components/modals/diagram-import/diagram-import.js @@ -0,0 +1,152 @@ +/******************** +* Diagram Import JS * +*********************/ + +function initDiagramImport() { + const dialogElement = document.getElementById("diagram-import"); + const fileInput = document.getElementById("diagram-import-input"); + const okButton = document.getElementById("diagram-import-ok-btn"); + const errorMessageElement = document.getElementById("diagram-import-error-message"); + + function openDialog(isOpenManually = true) { + setVisibility(dialogElement, true, true); + dialogElement.dataset.isOpenManually = isOpenManually.toString(); + // reset or clear file input + fileInput.value = ""; + onFileInputChange(fileInput); + } + function closeDialog() { + fileInput.value = ""; // reset or clear + onFileInputChange(fileInput); + dialogElement.removeAttribute("data-is-open-manually"); + setVisibility(dialogElement, false); + } + + function onFileInputChange(fileInput) { + errorMessageElement.innerText = ""; + okButton.disabled = fileInput.files?.length < 1; + } + + function checkFileLocally(file) { + function getImageFileType({name, type}) { + const supported = ["png", "svg"]; + // get type by mime type + let fileType = supported.filter(t => type.toLowerCase().indexOf(t) !== -1)[0]; + if (fileType) return fileType; + // fallback: get type by filename extension + if (name.indexOf(".") === -1) return undefined; + const ext = name.substring(name.lastIndexOf(".")+1).toLowerCase(); + return supported.filter(t => ext === t)[0]; + } + function isDiagramCode({name, type}) { + // get type by mime type + let supported = ["plain", "text", "plantuml", "puml"]; + if (supported.filter(t => type.toLowerCase().indexOf(t) !== -1).length > 0) { + return true; + } + // fallback: get type by filename extension + if (name.indexOf(".") === -1) return false; + const ext = name.substring(name.lastIndexOf('.')+1).toLowerCase(); + supported = ["txt", "puml", "plantuml"]; + return supported.filter(t => ext === t).length > 0; + } + + const type = getImageFileType(file); + const isCode = type === undefined ? isDiagramCode(file) : false; + if (!type && !isCode) { + errorMessageElement.innerText = "File not supported. " + + "Only PNG and SVG diagram images as well as PlantUML code text files are supported." + } + return { type, isDiagramCode: isCode, valid: type || isCode }; + } + + function importDiagram(file, fileCheck) { + function loadDiagram(code) { + setEditorValue(document.editor, code); + } + function requestMetadata(file) { + const fd = new FormData(); + fd.append("diagram", file, file.name); + return makeRequest("POST", "metadata", { + data: fd, + responseType: "json", + headers: { "Accept": "application/json" }, + }); + } + + dialogElement.classList.add("wait"); + return new Promise((resolve, reject) => { + if (fileCheck.type) { + // upload diagram image, get meta data from server and load diagram from result + requestMetadata(file).then( + metadata => { loadDiagram(metadata.decoded); resolve(); }, + ({ response }) => { errorMessageElement.innerText = response.message || response; reject(); } + ); + } else if (fileCheck.isDiagramCode) { + // read code (text) file + const reader = new FileReader(); + reader.onload = event => loadDiagram(event.target.result); + reader.readAsText(file); + resolve(); + } else { + // this error should already be handled. + errorMessageElement.innerText = "File not supported. " + + "Only PNG and SVG diagram images as well as PlantUML code text files are supported."; + reject(); + } + }).then(() => closeDialog(), () => {}).finally(() => dialogElement.classList.remove("wait")); + } + + function onGlobalDragEnter(event) { + event.stopPropagation(); + event.preventDefault(); + if (!isVisible(dialogElement)) { + openDialog(false); + } + } + + function onFileInputDragOver(event) { + event.stopPropagation(); + event.preventDefault(); + if (event.dataTransfer !== null) { + event.dataTransfer.dropEffect = "copy"; + } + } + function onFileInputDrop(event) { + function stop() { + event.stopPropagation(); + event.preventDefault(); + } + const files = event.dataTransfer.files || event.target.files; + if (!files || files.length < 1) { + return stop(); + } + const file = files[0]; + const fileCheck = checkFileLocally(file); + if (!fileCheck.valid) { + return stop(); + } + if (dialogElement.dataset.isOpenManually === "true") { + return; // let file input handle this event => no `stop()`! + } + // drop and go - close modal without additional ok button click + stop(); + importDiagram(file, fileCheck); + } + + // global drag&drop events + window.addEventListener("dragenter", onGlobalDragEnter, false); + // diagram import dialog drag&drop events + fileInput.addEventListener("dragenter", event => event.target.classList.add("drop-able"), false); + fileInput.addEventListener("dragover", onFileInputDragOver, false); + fileInput.addEventListener("dragexit", event => event.target.classList.remove("drop-able"), false); + fileInput.addEventListener("drop", onFileInputDrop, false); + fileInput.addEventListener("change", event => onFileInputChange(event.target)); + // ok button + okButton.addEventListener("click", () => { + const file = fileInput.files[0]; // should be always a valid file + importDiagram(file, checkFileLocally(file)); // otherwise button should be disabled + }); + // register model listeners + registerModalListener("diagram-import", openDialog, closeDialog); +} diff --git a/src/main/webapp/resource/diagram-import.jsp b/src/main/webapp/components/modals/diagram-import/diagram-import.jsp similarity index 93% rename from src/main/webapp/resource/diagram-import.jsp rename to src/main/webapp/components/modals/diagram-import/diagram-import.jsp index e5ea809..e38a8d7 100644 --- a/src/main/webapp/resource/diagram-import.jsp +++ b/src/main/webapp/components/modals/diagram-import/diagram-import.jsp @@ -5,7 +5,7 @@
diff --git a/src/main/webapp/components/preview/diagram/preview-diagram.css b/src/main/webapp/components/preview/diagram/preview-diagram.css new file mode 100644 index 0000000..f84b184 --- /dev/null +++ b/src/main/webapp/components/preview/diagram/preview-diagram.css @@ -0,0 +1,43 @@ +/********************** +* Preview Diagram CSS * +***********************/ + +.diagram { + height: 100%; + overflow: auto; +} +.diagram[data-diagram-type="pdf"] { + overflow: hidden; +} +.diagram > div { + margin: 1rem 0; + text-align: center; +} +.diagram[data-diagram-type="pdf"] > div { + height: 20em; + width: 100%; +} +.diagram img, .diagram svg, .diagram pre { + border: 3px solid var(--border-color); + box-sizing: border-box; + padding: 10px; +} +@media screen and (min-width: 900px) { + .diagram { + position: relative; + } + .diagram > div { + margin: 0; + } + .diagram:not([data-diagram-type="pdf"]) > div { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + max-height: 100%; + max-width: 100%; + } + .diagram[data-diagram-type="pdf"] > div { + height: 100%; + } +} diff --git a/src/main/webapp/components/preview/diagram/preview-diagram.js b/src/main/webapp/components/preview/diagram/preview-diagram.js new file mode 100644 index 0000000..73c5d82 --- /dev/null +++ b/src/main/webapp/components/preview/diagram/preview-diagram.js @@ -0,0 +1,77 @@ +/********************* +* Preview Diagram JS * +**********************/ + +async function initializeDiagram() { + if (document.appConfig.diagramPreviewType !== "png") { + // NOTE: "png" is preloaded from the server + return setDiagram( + document.appConfig.diagramPreviewType, + document.appData.encodedDiagram, + document.appData.index + ); + } +} + +async function setDiagram(type, encodedDiagram, index) { + const container = document.getElementById("diagram"); + const png = document.getElementById("diagram-png"); + const txt = document.getElementById("diagram-txt"); + const pdf = document.getElementById("diagram-pdf"); + // NOTE: the map and svg elements will be overwitten, hence can not be cached + + async function requestDiagram(type, encodedDiagram, index) { + return makeRequest("GET", buildUrl(type, encodedDiagram, index)); + } + function setDiagramMap(mapString) { + const mapEl = document.getElementById("plantuml_map"); + const mapBtn = document.getElementById("map-diagram-link"); + if (mapString) { + const div = document.createElement("div"); + div.innerHTML = mapString; + mapEl.parentNode.replaceChild(div.firstChild, mapEl); + setVisibility(mapBtn, true); + } else { + removeChildren(mapEl); + setVisibility(mapBtn, false); + } + } + function setSvgDiagram(svgString) { + const svgEl = document.getElementById("diagram-svg"); + const div = document.createElement("div"); + div.innerHTML = svgString; + const newSvg = div.querySelector("svg"); + newSvg.id = "diagram-svg"; + newSvg.classList = svgEl.classList; + newSvg.style.cssText = svgEl.style.cssText; + svgEl.parentNode.replaceChild(newSvg, svgEl); + } + function setDiagramVisibility(type) { + const map = document.getElementById("plantuml_map"); + const svg = document.getElementById("diagram-svg"); + container.setAttribute("data-diagram-type", type); + setVisibility(png, type === "png"); + setVisibility(map, type === "png"); + setVisibility(svg, type === "svg"); + setVisibility(txt, type === "txt"); + setVisibility(pdf, type === "pdf"); + } + // update diagram + if (type === "png") { + png.src = buildUrl("png", encodedDiagram, index); + const map = await requestDiagram("map", encodedDiagram, index); + setDiagramMap(map); + } else if (type === "svg") { + const svg = await requestDiagram("svg", encodedDiagram, index); + setSvgDiagram(svg); + } else if (type === "txt") { + txt.innerHTML = await requestDiagram("txt", encodedDiagram, index); + } else if (type === "pdf") { + pdf.data = buildUrl("pdf", encodedDiagram, index); + } else { + const message = "unknown diagram type: " + type; + (console.error || console.log)(message); + return Promise.reject(message); + } + setDiagramVisibility(type); +} diff --git a/src/main/webapp/components/preview/diagram/preview-diagram.jsp b/src/main/webapp/components/preview/diagram/preview-diagram.jsp new file mode 100644 index 0000000..498d584 --- /dev/null +++ b/src/main/webapp/components/preview/diagram/preview-diagram.jsp @@ -0,0 +1,19 @@ +
+
+ + PlantUML diagram + <% if (hasMap) { %> + <%= map %> + <% } else { %> + + <% } %> + + + + + + +
+
diff --git a/src/main/webapp/components/preview/menu/preview-menu.css b/src/main/webapp/components/preview/menu/preview-menu.css new file mode 100644 index 0000000..49e127b --- /dev/null +++ b/src/main/webapp/components/preview/menu/preview-menu.css @@ -0,0 +1,38 @@ +/******************* +* Preview Menu CSS * +********************/ + +.preview-menu { + margin-left: 5%; + margin-right: 5%; +} +.diagram-link img, .btn-dock { + width: 2.5rem; +} +.btn-settings { + width: 2.2rem; + margin-left: auto; + margin-right: 0.25rem; +} +.menu-r { + min-width: 3rem; +} +.menu-r .btn-float-r { + float: right; + margin-left: 0.25rem; + text-align: right; +} +.diagram-links { + align-items: center; + display: flex; +} +.diagram-link { + margin-left: 0.25rem; + margin-right: 0.25rem; +} +.diagram-links .diagram-link:first-of-type { + margin-left: 0.5rem; +} +.diagram-links .diagram-link:last-of-type { + margin-right: 0; +} diff --git a/src/main/webapp/components/preview/menu/preview-menu.jsp b/src/main/webapp/components/preview/menu/preview-menu.jsp new file mode 100644 index 0000000..6f2f996 --- /dev/null +++ b/src/main/webapp/components/preview/menu/preview-menu.jsp @@ -0,0 +1,57 @@ + diff --git a/src/main/webapp/components/preview/paginator/paginator.css b/src/main/webapp/components/preview/paginator/paginator.css new file mode 100644 index 0000000..3d9ed01 --- /dev/null +++ b/src/main/webapp/components/preview/paginator/paginator.css @@ -0,0 +1,8 @@ +/**************** +* Paginator CSS * +*****************/ + +#paginator { + text-align: center; + margin-bottom: 1rem; +} diff --git a/src/main/webapp/components/preview/paginator/paginator.js b/src/main/webapp/components/preview/paginator/paginator.js new file mode 100644 index 0000000..1e7ef96 --- /dev/null +++ b/src/main/webapp/components/preview/paginator/paginator.js @@ -0,0 +1,62 @@ +/*************** +* Paginator JS * +***************/ + +function getNumberOfDiagramPagesFromCode(code) { + // count `newpage` inside code + // known issue: a `newpage` starting in a newline inside a multiline comment will also be counted + return code.match(/^\s*newpage\s?.*$/gm)?.length + 1 || 1; +} + +function updatePaginatorSelection() { + const paginator = document.getElementById("paginator"); + const index = document.appData.index; + if (index === undefined || paginator.childNodes.length <= index) { + for (const node of paginator.childNodes) { + node.checked = false; + } + } else { + paginator.childNodes[index].checked = true; + } +} + +const updatePaginator = (function() { + function updateNumberOfPagingElements(paginator, pages) { + // remove elements (buttons) if there are to many + while (paginator.childElementCount > pages) { + paginator.removeChild(paginator.lastChild) + } + // add elements (buttons) if there are to less + while (paginator.childElementCount < pages) { + const radioBtn = document.createElement("input"); + radioBtn.name = "paginator"; + radioBtn.type = "radio"; + radioBtn.value = paginator.childElementCount; + radioBtn.addEventListener("click", (event) => { + sendMessage({ + sender: "paginator", + data: { index: event.target.value }, + synchronize: true, + }); + }); + paginator.appendChild(radioBtn); + } + } + return function() { + const paginator = document.getElementById("paginator"); + const pages = document.appData.numberOfDiagramPages; + if (pages > 1) { + updateNumberOfPagingElements(paginator, pages); + setVisibility(paginator, true); + } else { + setVisibility(paginator, false); + } + }; +})(); + +function initializePaginator() { + if (document.appData.numberOfDiagramPages > 1) { + updatePaginator(); + updatePaginatorSelection(); + } +} diff --git a/src/main/webapp/components/preview/paginator/paginator.jsp b/src/main/webapp/components/preview/paginator/paginator.jsp new file mode 100644 index 0000000..95c1fa7 --- /dev/null +++ b/src/main/webapp/components/preview/paginator/paginator.jsp @@ -0,0 +1 @@ + diff --git a/src/main/webapp/components/preview/preview.css b/src/main/webapp/components/preview/preview.css new file mode 100644 index 0000000..46b3771 --- /dev/null +++ b/src/main/webapp/components/preview/preview.css @@ -0,0 +1,15 @@ +/************** +* Preview CSS * +***************/ + +.previewer-container { + height: 100%; +} +@media screen and (max-width: 900px) { + .previewer-container { + height: initial; + } + .previewer-main { + flex: none; + } +} diff --git a/src/main/webapp/components/preview/preview.js b/src/main/webapp/components/preview/preview.js new file mode 100644 index 0000000..c14e53b --- /dev/null +++ b/src/main/webapp/components/preview/preview.js @@ -0,0 +1,42 @@ +/************* +* Preview JS * +**************/ + +async function initPreview(view) { + const btnUndock = document.getElementById("btn-undock"); + const btnDock = document.getElementById("btn-dock"); + const editorContainer = document.getElementById("editor-main-container"); + const previewContainer = document.getElementById("previewer-main-container"); + + function hidePreview() { + setVisibility(btnUndock, false); + // if not opened via button and therefore a popup, `window.close` won't work + setVisibility(btnDock, window.opener); + if (editorContainer) editorContainer.style.width = "100%"; + if (previewContainer) setVisibility(previewContainer, false); + } + function showPreview() { + setVisibility(btnUndock, true); + setVisibility(btnDock, false); + if (editorContainer) editorContainer.style.removeProperty("width"); + if (previewContainer) setVisibility(previewContainer, true); + } + function undock() { + const url = new URL(window.location.href); + url.searchParams.set("view", "previewer"); + const previewer = window.open(url, "PlantUML Diagram Previewer", "popup"); + if (previewer) { + previewer.onbeforeunload = showPreview; + hidePreview(); + } + } + // add listener + btnUndock.addEventListener("click", undock); + // init preview components + await initializeDiagram(); + initializePaginator() + // check preview visibility + if (["previewer", "editor"].includes(view)) { + hidePreview(); + } +} diff --git a/src/main/webapp/components/preview/preview.jsp b/src/main/webapp/components/preview/preview.jsp new file mode 100644 index 0000000..9fa91ef --- /dev/null +++ b/src/main/webapp/components/preview/preview.jsp @@ -0,0 +1,16 @@ +
+ <%@ include file="/components/preview/menu/preview-menu.jsp" %> +
+ <%@ include file="/components/preview/paginator/paginator.jsp" %> + +
+ <%@ include file="/components/preview/diagram/preview-diagram.jsp" %> +
+ <% if (showSocialButtons) { %> +
+ <%@ include file="/components/preview/social-buttons.jsp" %> +
+ <% } %> + + <%@ include file="/components/modals/settings/settings.jsp" %> +
diff --git a/src/main/webapp/resource/socialbuttons2.jsp b/src/main/webapp/components/preview/social-buttons.jsp similarity index 100% rename from src/main/webapp/resource/socialbuttons2.jsp rename to src/main/webapp/components/preview/social-buttons.jsp diff --git a/src/main/webapp/index.jsp b/src/main/webapp/index.jsp index 7732650..6506a1b 100644 --- a/src/main/webapp/index.jsp +++ b/src/main/webapp/index.jsp @@ -16,58 +16,26 @@ - <%@ include file="resource/htmlheadbase.jsp" %> + <%@ include file="/components/app-head.jsp" %> PlantUML Server
-

PlantUML Server

- <% if (showSocialButtons) { %> - <%@ include file="resource/socialbuttons1.html" %> - <% } %> - <% if (showGithubRibbon) { %> - <%@ include file="resource/githubribbon.html" %> - <% } %> -

Create your PlantUML diagrams directly in your browser!

+ <%@ include file="/components/header/header.jsp" %>
-
-
-
- - -
-
-
- -
-
- - -
-
-
+ <%@ include file="/components/editor/editor.jsp" %>
- <%@ include file="resource/preview.jsp" %> + <%@ include file="/components/preview/preview.jsp" %>
- <%@ include file="resource/diagram-import.jsp" %> - <%@ include file="resource/diagram-export.jsp" %> + <%@ include file="/components/modals/diagram-import/diagram-import.jsp" %> + <%@ include file="/components/modals/diagram-export/diagram-export.jsp" %>
diff --git a/src/main/webapp/js/communication/browser.js b/src/main/webapp/js/communication/browser.js new file mode 100644 index 0000000..0c8ee2d --- /dev/null +++ b/src/main/webapp/js/communication/browser.js @@ -0,0 +1,123 @@ +/************************ +* Browser Communication * +************************* +* send and receive data object: +* { +* sender: string = ["editor"|"url"|"paginator"|"settings"|"file-drop"], +* data: { +* encodedDiagram: string | undefined, +* index: integer | undefined, +* numberOfDiagramPages: integer | undefined, +* appConfig: object | undefined +* } | undefined, +* synchronize: boolean = false, +* reload: boolean = false, // reload page +* force: boolean = false // force synchronize or reload +* } +*************************/ + +const { sendMessage, suppressNextMessage, initAppCommunication } = (function() { + const BROADCAST_CHANNEL = "plantuml-server"; + + const { suppressNextMessage, isMessageSuppressed } = (function() { + const suppressMessages = []; + function suppressNextMessage(sender, condition=undefined) { + suppressMessages.push({ sender, condition }); + } + function isMessageSuppressed(data) { + for (let i = 0; i < suppressMessages.length; i++) { + const suppressMessage = suppressMessages[i]; + if (!suppressMessage.sender || suppressMessage.sender === data.sender) { + if (!suppressMessage.condition || suppressMessage.condition(data)) { + suppressMessages.splice(i, 1); + return true; + } + } + } + return false; + } + return { suppressNextMessage, isMessageSuppressed }; + })(); + + function sendMessage(data) { + if (isMessageSuppressed(data)) return; + (new BroadcastChannel(BROADCAST_CHANNEL)).postMessage(data); + } + + function initAppCommunication() { + function updateReceiveMessageData(data) { + if (!data || Object.keys(data).length === 0) return {}; + + const changedFlags = {}; + if ("encodedDiagram" in data && data.encodedDiagram !== document.appData.encodedDiagram) { + document.appData.encodedDiagram = data.encodedDiagram; + changedFlags.diagram = true; + } + if ("index" in data && data.index !== document.appData.index) { + document.appData.index = data.index; + changedFlags.index = true; + } + if ("numberOfDiagramPages" in data && data.numberOfDiagramPages !== document.appData.numberOfDiagramPages) { + document.appData.numberOfDiagramPages = data.numberOfDiagramPages; + changedFlags.numberOfDiagramPages = true; + } + if ("appConfig" in data && data.appConfig !== document.appConfig) { + document.appConfig = data.appConfig; + changedFlags.appConfig = true; + } + return changedFlags; + } + + async function receiveMessage(event) { + async function updateStaticPageData(sender) { + document.appConfig.autoRefreshState = "syncing"; + const encodedDiagram = document.appData.encodedDiagram; + const index = document.appData.index; + + if (sender !== "url" && document.getElementById("url")) { + // update URL input + setUrlValue(undefined, { encodedDiagram, index }, { suppressEditorChangedMessage: true }); + } + // update diagram image + await setDiagram(document.appConfig.diagramPreviewType, encodedDiagram, index); + // update external diagram links + for (let target of document.getElementsByClassName("diagram-link")) { + target.href = buildUrl(target.dataset.imgType, encodedDiagram, index); + } + // update browser url as well as the browser history + const url = replaceUrl(window.location.href, encodedDiagram, index).url; + history.replaceState(history.stat, document.title, url); + + // set auto refresh state to complete + document.appConfig.autoRefreshState = "complete"; + } + + const data = event.data.data; + const force = event.data.force || false; + const changedFlags = updateReceiveMessageData(data); + if (event.data.synchronize === true) { + if (force || changedFlags.diagram || changedFlags.index || changedFlags.appConfig) { + await updateStaticPageData(event.data.sender); + } + if (force || changedFlags.numberOfDiagramPages) { + updatePaginator(); + } + if (force || changedFlags.numberOfDiagramPages || changedFlags.index) { + updatePaginatorSelection(); + } + if (changedFlags.appConfig) { + applyConfig(); + } + } + if (event.data.reload === true) { + window.location.reload(); + } + } + + // create broadcast channel + const bc = new BroadcastChannel(BROADCAST_CHANNEL); + bc.onmessage = receiveMessage; + } + + return { sendMessage, suppressNextMessage, initAppCommunication }; +})(); diff --git a/src/main/webapp/js/communication/server.js b/src/main/webapp/js/communication/server.js new file mode 100644 index 0000000..09d9e54 --- /dev/null +++ b/src/main/webapp/js/communication/server.js @@ -0,0 +1,20 @@ +/*********************** +* Server Communication * +************************/ + +function makeRequest( + method, + url, + { + data = null, + headers = { "Content-Type": "text/plain" }, + responseType = "text", + baseUrl = "", + } = {} +) { + return PlantUmlLanguageFeatures.makeRequest( + method, + url, + { data, headers, responseType, baseUrl } + ); +} diff --git a/src/main/webapp/js/config/config.js b/src/main/webapp/js/config/config.js new file mode 100644 index 0000000..ac1edf5 --- /dev/null +++ b/src/main/webapp/js/config/config.js @@ -0,0 +1,45 @@ +/***************** +* Configurations * +******************/ + +const { applyConfig, updateConfig } = (function() { + const DEFAULT_APP_CONFIG = { + changeEventsEnabled: true, + // `autoRefreshState` is mostly used for unit testing puposes. + // states: disabled | waiting | started | syncing | complete + autoRefreshState: "disabled", + theme: undefined, // dark | light (will be set via `initTheme` if undefined) + diagramPreviewType: "png", + editorWatcherTimeout: 500, + editorCreateOptions: { + automaticLayout: true, + fixedOverflowWidgets: true, + minimap: { enabled: false }, + scrollbar: { alwaysConsumeMouseWheel: false }, + scrollBeyondLastLine: false, + tabSize: 2, + theme: "vs", // "vs-dark" + } + }; + + function applyConfig() { + setTheme(document.appConfig.theme); + document.editor?.updateOptions(document.appConfig.editorCreateOptions); + document.settingsEditor?.updateOptions(document.appConfig.editorCreateOptions); + } + function updateConfig(appConfig) { + localStorage.setItem("document.appConfig", JSON.stringify(appConfig)); + sendMessage({ + sender: "config", + data: { appConfig }, + synchronize: true, + }); + } + + document.appConfig = Object.assign({}, window.opener?.document.appConfig); + if (Object.keys(document.appConfig).length === 0) { + document.appConfig = JSON.parse(localStorage.getItem("document.appConfig")) || DEFAULT_APP_CONFIG; + } + + return { applyConfig, updateConfig }; +})(); diff --git a/src/main/webapp/js/language/completion/emojis.js b/src/main/webapp/js/language/completion/emojis.js new file mode 100644 index 0000000..38caccf --- /dev/null +++ b/src/main/webapp/js/language/completion/emojis.js @@ -0,0 +1,55 @@ +/********************************************** +* PlantUML Language Emoji Completion Provider * +***********************************************/ + +PlantUmlLanguageFeatures.prototype.getEmojis = (function(){ + let emojis = undefined; + return async function() { + if (emojis === undefined) { + emojis = await PlantUmlLanguageFeatures.makeRequest("GET", "ui-helper?request=emojis"); + } + return emojis; + } +})(); + +PlantUmlLanguageFeatures.prototype.registerEmojiCompletion = function() { + const createEmojiProposals = async (range, filter = undefined) => { + const emojis = await this.getEmojis(); + return emojis?.filter(([unicode, name]) => filter ? unicode.includes(filter) || name?.includes(filter) : true) + .map(([unicode, name]) => { + // NOTE: load images direct from GitHub source: https://github.com/twitter/twemoji#download + const emojiUrl = "https://raw.githubusercontent.com/twitter/twemoji/gh-pages/v/13.1.0/svg/" + unicode + ".svg"; + const docHint = (name) ? name + " (" + unicode + ")" : unicode; + const isUnicode = !name || (filter && unicode.includes(filter)); + const label = isUnicode ? unicode : name; + return { + label: label, + kind: monaco.languages.CompletionItemKind.Constant, + documentation: { + //supportHtml: true, // also a possibility but quite limited html + value: "![emoji](" + emojiUrl + ")   " + docHint + }, + insertText: label + ":>", + range: range + }; + }) || []; + }; + + monaco.languages.registerCompletionItemProvider(PlantUmlLanguageFeatures.languageSelector, { + triggerCharacters: [":"], + provideCompletionItems: async (model, position) => { + const textUntilPosition = model.getValueInRange({ + startLineNumber: position.lineNumber, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column, + }); + const match = textUntilPosition.match(/<:([^\s>]*)$/); + if (match) { + const suggestions = await createEmojiProposals(this.getWordRange(model, position), match[1]); + return { suggestions }; + } + return { suggestions: [] }; + } + }); +}; diff --git a/src/main/webapp/js/language/completion/icons.js b/src/main/webapp/js/language/completion/icons.js new file mode 100644 index 0000000..76c8d33 --- /dev/null +++ b/src/main/webapp/js/language/completion/icons.js @@ -0,0 +1,54 @@ +/********************************************* +* PlantUML Language Icon Completion Provider * +**********************************************/ + +PlantUmlLanguageFeatures.prototype.getIcons = (function(){ + let icons = undefined; + return async function() { + if (icons === undefined) { + icons = await PlantUmlLanguageFeatures.makeRequest("GET", "ui-helper?request=icons"); + } + return icons; + } +})(); + +PlantUmlLanguageFeatures.prototype.registerIconCompletion = function() { + const createIconProposals = async (range, filter = undefined) => { + const icons = await this.getIcons(); + return icons?.filter(icon => filter ? icon.includes(filter) : true) + .map(icon => { + // NOTE: markdown image path inside suggestions seems to have rendering issues while using relative paths + const iconUrl = PlantUmlLanguageFeatures.absolutePath( + PlantUmlLanguageFeatures.baseUrl + "ui-helper?request=icons.svg#" + icon + ); + return { + label: icon, + kind: monaco.languages.CompletionItemKind.Constant, + documentation: { + //supportHtml: true, // also a possibility but quite limited html + value: "![icon](" + iconUrl + ")   " + icon + }, + insertText: icon + ">", + range: range + }; + }) || []; + }; + + monaco.languages.registerCompletionItemProvider(PlantUmlLanguageFeatures.languageSelector, { + triggerCharacters: ["&"], + provideCompletionItems: async (model, position) => { + const textUntilPosition = model.getValueInRange({ + startLineNumber: position.lineNumber, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column, + }); + const match = textUntilPosition.match(/<&([^\s>]*)$/); + if (match) { + const suggestions = await createIconProposals(this.getWordRange(model, position), match[1]); + return { suggestions }; + } + return { suggestions: [] }; + } + }); +}; diff --git a/src/main/webapp/js/language/completion/themes.js b/src/main/webapp/js/language/completion/themes.js new file mode 100644 index 0000000..ca680e2 --- /dev/null +++ b/src/main/webapp/js/language/completion/themes.js @@ -0,0 +1,58 @@ +/********************************************** +* PlantUML Language Theme Completion Provider * +***********************************************/ + +PlantUmlLanguageFeatures.prototype.getThemes = (function(){ + let themes = undefined; + return async function() { + if (themes === undefined) { + themes = await PlantUmlLanguageFeatures.makeRequest("GET", "ui-helper?request=themes"); + } + return themes; + } +})(); + +PlantUmlLanguageFeatures.prototype.registerThemeCompletion = function() { + const createThemeProposals = async (range, filter = undefined) => { + const themes = await this.getThemes(); + return themes?.filter(theme => filter ? theme.includes(filter) : true) + .map(theme => ({ + label: theme, + kind: monaco.languages.CompletionItemKind.Text, + documentation: "PlantUML " + theme + " theme", + insertText: theme, + range: range, + })) || []; + }; + + monaco.languages.registerCompletionItemProvider(PlantUmlLanguageFeatures.languageSelector, { + triggerCharacters: [" "], + provideCompletionItems: async (model, position) => { + const textUntilPosition = model.getValueInRange({ + startLineNumber: position.lineNumber, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column, + }); + if (textUntilPosition.match(/^\s*!(t(h(e(m(e)?)?)?)?)?$/)) { + return { + suggestions: [ + { + label: 'theme', + kind: monaco.languages.CompletionItemKind.Keyword, + documentation: "PlantUML theme command", + insertText: 'theme', + range: this.getWordRange(model, position), + } + ] + }; + } + const match = textUntilPosition.match(/^\s*!theme\s+([^\s]*)$/); + if (match) { + const suggestions = await createThemeProposals(this.getWordRange(model, position), match[1]); + return { suggestions }; + } + return { suggestions: [] }; + } + }); +}; diff --git a/src/main/webapp/js/language/completion/utils.js b/src/main/webapp/js/language/completion/utils.js new file mode 100644 index 0000000..ca55b19 --- /dev/null +++ b/src/main/webapp/js/language/completion/utils.js @@ -0,0 +1,13 @@ +/********************************************** +* PlantUML Language Completion Provider Utils * +***********************************************/ + +PlantUmlLanguageFeatures.prototype.getWordRange = function(model, position) { + const word = model.getWordUntilPosition(position); + return { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; +} diff --git a/src/main/webapp/js/language/language.js b/src/main/webapp/js/language/language.js new file mode 100644 index 0000000..5ea370a --- /dev/null +++ b/src/main/webapp/js/language/language.js @@ -0,0 +1,92 @@ +/************************************************ +* Monaco Editor PlantUML Language Features Base * +*************************************************/ +"use strict"; + +/** + * Monaco Editor PlantUML Language Features. + * + * @param {boolean} [initialize] `true` if all default validation and code completion + * functions should be activated; otherwise `false` + * + * @example + * ```js + * plantumlFeatures = new PlantUmlLanguageFeatures(); + * const model = monaco.editor.createModel(initCode, "apex", uri); + * model.onDidChangeContent(() => plantumlFeatures.validateCode(model)); + * ``` + */ +const PlantUmlLanguageFeatures = function(initialize = true) { + if (initialize) { + // initialize all validation and code completion methods + this.addStartEndValidationListeners(); + this.registerThemeCompletion(); + this.registerIconCompletion(); + this.registerEmojiCompletion(); + } +}; + +PlantUmlLanguageFeatures.baseUrl = ""; +PlantUmlLanguageFeatures.setBaseUrl = function(baseUrl) { + if (baseUrl === null || baseUrl === undefined) { + baseUrl = ""; + } else if (baseUrl !== "") { + if (baseUrl.slice(-1) !== "/") { + baseUrl = baseUrl + "/"; // add tailing "/" + } + } + PlantUmlLanguageFeatures.baseUrl = baseUrl; +} + +PlantUmlLanguageFeatures.languageSelector = ["apex", "plantuml"]; +PlantUmlLanguageFeatures.setLanguageSelector = function(languageSelector) { + PlantUmlLanguageFeatures.languageSelector = languageSelector; +} + +PlantUmlLanguageFeatures.makeRequest = function( + method, + url, + { + data = null, + headers = { "Content-Type": "text/plain" }, + responseType = "json", + baseUrl = PlantUmlLanguageFeatures.baseUrl, + } = {} +) { + function getResolveResponse(xhr) { + return responseType === "json" ? xhr.response : xhr.responseText; + } + function getRejectResponse(xhr) { + return responseType === "json" + ? { status: xhr.status, response: xhr.response } + : { status: xhr.status, responseText: xhr.responseText }; + } + const targetUrl = !baseUrl ? url : baseUrl.replace(/\/*$/g, "/") + url; + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE) { + if (xhr.status >= 200 && xhr.status <= 300) { + resolve(getResolveResponse(xhr)); + } else { + reject(getRejectResponse(xhr)); + } + } + } + xhr.open(method, targetUrl, true); + xhr.responseType = responseType; + headers && Object.keys(headers).forEach(key => xhr.setRequestHeader(key, headers[key])); + xhr.send(data); + }); +} + +PlantUmlLanguageFeatures.absolutePath = function(path) { + if (path.startsWith("http")) return path; + if (path.startsWith("//")) return window.location.protocol + path; + if (path.startsWith("/")) return window.location.origin + path; + + if (path.slice(0, 2) == "./") path = path.slice(2); + let base = (document.querySelector("base") || {}).href || window.location.origin; + if (base.slice(-1) == "/") base = base.slice(0, -1); + return base + "/" + path; +} diff --git a/src/main/webapp/js/language/validation/listeners/start-end-validation.js b/src/main/webapp/js/language/validation/listeners/start-end-validation.js new file mode 100644 index 0000000..9a70eae --- /dev/null +++ b/src/main/webapp/js/language/validation/listeners/start-end-validation.js @@ -0,0 +1,103 @@ +/**************************************** +* Language Start-End Validation Feature * +*****************************************/ + +/** + * Add PlantUML `@start` and `@end` command validation. + */ +PlantUmlLanguageFeatures.prototype.addStartEndValidationListeners = function() { + let diagramType = undefined; + let startCounter = 0; + let endCounter = 0; + + // reset validation cache + this.addValidationEventListener("before", () => { + diagramType = undefined; + startCounter = 0; + endCounter = 0; + }); + + // @start should be the first command + this.addValidationEventListener("code", ({ model, code }) => { + const match = code.match(/^(?:(?:'.*)|\s)*@start(\w+)/); + if (match) { + diagramType = match[1]; + return; // diagram code starts with a `@start` + } + return { + message: "PlantUML diagrams should begin with the `@start` command and `@start` should also be the first command.", + severity: monaco.MarkerSeverity.Warning, + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: model.getLineLength(1) + 1, + }; + }); + + // @end should be the last command and should be of the same type (e.g. @startjson ... @endjson) + this.addValidationEventListener("code", ({ model, code }) => { + const lineCount = model.getLineCount(); + const match = code.match(/\s+@end(\w+)(?:(?:'.*)|\s)*$/); + if (match) { + if (diagramType === match[1]) { + return; // diagram code ends with a `@end` of the same type as the `@start` + } + return { + message: "PlantUML diagrams should start and end with the type.\nExample: `@startjson ... @endjson`", + severity: monaco.MarkerSeverity.Error, + startLineNumber: lineCount, + startColumn: 1, + endLineNumber: lineCount, + endColumn: model.getLineLength(lineCount) + 1, + }; + } + return { + message: "PlantUML diagrams should end with the `@end` command and `@end` should also be the last command.", + severity: monaco.MarkerSeverity.Warning, + startLineNumber: lineCount, + startColumn: 1, + endLineNumber: lineCount, + endColumn: model.getLineLength(lineCount) + 1, + }; + }); + + // @start should only be used once + this.addValidationEventListener("line", ({ range, line }) => { + const match = line.match(/^\s*@start(\w+)(?:\s+.*)?$/); + if (!match) return; + + startCounter += 1; + if (startCounter > 1) { + const word = "@start" + match[1]; + const wordIndex = line.indexOf(word); + return { + message: "Multiple @start commands detected.", + severity: monaco.MarkerSeverity.Warning, + startLineNumber: range.startLineNumber, + startColumn: wordIndex + 1, + endLineNumber: range.endLineNumber, + endColumn: wordIndex + word.length + 1, + }; + } + }); + + // @end should only be used once + this.addValidationEventListener("line", ({ range, line }) => { + const match = line.match(/^\s*@end(\w+)(?:\s+.*)?$/); + if (!match) return; + + endCounter += 1; + if (endCounter > 1) { + const word = "@end" + match[1]; + const wordIndex = line.indexOf(word); + return { + message: "Multiple @end commands detected.", + severity: monaco.MarkerSeverity.Warning, + startLineNumber: range.startLineNumber, + startColumn: wordIndex + 1, + endLineNumber: range.endLineNumber, + endColumn: wordIndex + word.length + 1, + }; + } + }); +}; diff --git a/src/main/webapp/js/language/validation/validation.js b/src/main/webapp/js/language/validation/validation.js new file mode 100644 index 0000000..98b0115 --- /dev/null +++ b/src/main/webapp/js/language/validation/validation.js @@ -0,0 +1,73 @@ +/******************************************** +* PlantUML Language Validation Feature Base * +*********************************************/ + +(function() { + + const validationEventListeners = {}; + + /** + * Add validation event listener. + * + * Validation Event Order: + * before -> code -> line -> after + * + * @param {("before"|"code"|"line"|"after")} type before|code|line|after event type + * @param {(event: any) => Promise|editor.IMarkerData|Promise|editor.IMarkerData[]|Promise|void} listener event listener + */ + PlantUmlLanguageFeatures.prototype.addValidationEventListener = function(type, listener) { + if (!["before", "code", "line", "after"].includes(type)) { + throw Error("Unknown validation event type: " + type); + } + validationEventListeners[type] = validationEventListeners[type] || []; + validationEventListeners[type].push(listener); + }; + + /** + * Validate PlantUML language of monaco editor model. + * + * @param {editor.ITextModel} model editor model to validate + * + * @returns editor markers as promise + * + * @example + * ```js + * validateCode(editor.getModel()) + * .then(markers => monaco.editor.setModelMarkers(model, "plantuml", markers)); + * ``` + */ + PlantUmlLanguageFeatures.prototype.validateCode = async function(model) { + const promises = []; + + // raise before events + promises.push(validationEventListeners.before?.map(listener => listener({ model }))); + + // raise code events + promises.push(validationEventListeners.code?.map(listener => listener({ model, code: model.getValue() }))); + + if (validationEventListeners.line && validationEventListeners.line.length > 0) { + // NOTE: lines and columns start at 1 + const lineCount = model.getLineCount(); + for (let lineNumber = 1; lineNumber <= lineCount; lineNumber++) { + const range = { + startLineNumber: lineNumber, + startColumn: 1, + endLineNumber: lineNumber, + endColumn: model.getLineLength(lineNumber) + 1, + }; + const line = model.getValueInRange(range); + // raise line events + promises.push(validationEventListeners.line?.map(listener => listener({ model, range, line, lineNumber, lineCount }))); + } + } + + // raise after events + promises.push(validationEventListeners.after?.map(listener => listener({ model }))); + + // collect all markers and ... + // - since each event can results in an array of markers -> `flat(1)` + // - since not each event has to results in markers and can be `undef + return Promise.all(promises).then(results => results.flat(1).filter(marker => marker)); + }; + +})(); diff --git a/src/main/webapp/js/utilities/dom-helpers.js b/src/main/webapp/js/utilities/dom-helpers.js new file mode 100644 index 0000000..0608408 --- /dev/null +++ b/src/main/webapp/js/utilities/dom-helpers.js @@ -0,0 +1,27 @@ +/************** +* DOM Helpers * +***************/ + +function removeChildren(element) { + if (element.replaceChildren) { + element.replaceChildren(); + } else { + element.innerHTML = ""; + } +} + +function isVisible(element) { + // `offsetParent` returns `null` if the element, or any of its parents, + // is hidden via the display style property. + // see: https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetParent + return (element.offsetParent !== null); +} + +function setVisibility(element, visibility, focus=false) { + if (visibility) { + element.style.removeProperty("display"); + if (focus) element.focus(); + } else { + element.style.display = "none"; + } +} diff --git a/src/main/webapp/js/utilities/os-helpers.js b/src/main/webapp/js/utilities/os-helpers.js new file mode 100644 index 0000000..d678b9a --- /dev/null +++ b/src/main/webapp/js/utilities/os-helpers.js @@ -0,0 +1,8 @@ +/************* +* OS Helpers * +**************/ + +const isMac = (function() { + const PLATFORM = navigator?.userAgentData?.platform || navigator?.platform || "unknown"; + return PLATFORM.match("Mac"); +})(); diff --git a/src/main/webapp/js/utilities/theme-helpers.js b/src/main/webapp/js/utilities/theme-helpers.js new file mode 100644 index 0000000..b5b3634 --- /dev/null +++ b/src/main/webapp/js/utilities/theme-helpers.js @@ -0,0 +1,39 @@ +/**************** +* Theme Helpers * +*****************/ + +function setTheme(theme) { + document.documentElement.setAttribute("data-theme", theme); +} + +function initTheme() { + function getBrowserThemePreferences() { + if (window.matchMedia("(prefers-color-scheme: dark)").matches) { + return "dark"; + } + if (window.matchMedia("(prefers-color-scheme: light)").matches) { + return "light"; + } + return undefined; + } + function changeEditorThemeSettingIfNecessary(theme) { + if (theme === "dark" && document.appConfig.editorCreateOptions.theme === "vs") { + document.appConfig.editorCreateOptions.theme = "vs-dark"; + } + if (theme === "light" && document.appConfig.editorCreateOptions.theme === "vs-dark") { + document.appConfig.editorCreateOptions.theme = "vs"; + } + } + function onMediaColorPreferencesChanged(event) { + const theme = event.matches ? "dark" : "light"; + document.appConfig.theme = theme + changeEditorThemeSettingIfNecessary(theme); + updateConfig(document.appConfig); + } + // set theme to last saved settings or browser preference or "light" + document.appConfig.theme = document.appConfig.theme || getBrowserThemePreferences() || "light"; + setTheme(document.appConfig.theme); + changeEditorThemeSettingIfNecessary(document.appConfig.theme); + // listen to browser change event + window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", onMediaColorPreferencesChanged); +} diff --git a/src/main/webapp/js/utilities/url-helpers.js b/src/main/webapp/js/utilities/url-helpers.js new file mode 100644 index 0000000..cad026c --- /dev/null +++ b/src/main/webapp/js/utilities/url-helpers.js @@ -0,0 +1,53 @@ +/************** +* URL Helpers * +***************/ + +function resolvePath(path) { + return PlantUmlLanguageFeatures.absolutePath(path); +} + +function prepareUrl(url) { + if (!(url instanceof URL)) { + url = new URL(resolvePath(url)); + } + // pathname excluding context path + let base = new URL((document.querySelector("base") || {}).href || window.location.origin).pathname; + if (base.slice(-1) === "/") base = base.slice(0, -1); + const pathname = url.pathname.startsWith(base) ? url.pathname.slice(base.length) : url.pathname; + // same as `UrlDataExtractor.URL_PATTERN` + // regex = /\/\w+(?:\/(?\d+))?(?:\/(?[^\/]+))?\/?$/gm; + const regex = /\/\w+(?:\/(\d+))?(?:\/([^/]+))?\/?$/gm; + const match = regex.exec(pathname); + return [ url, pathname, { idx: match[1], encoded: match[2] } ]; +} + +function analyseUrl(url) { + let _, idx, encoded; + [url, _, { idx, encoded }] = prepareUrl(url); + return { + index: idx, + encodedDiagram: encoded || url.searchParams.get("url"), + }; +} + +function replaceUrl(url, encodedDiagram, index) { + let oldPathname, encoded; + [url, oldPathname, { encoded }] = prepareUrl(url); + let pathname = oldPathname.slice(1); + pathname = pathname.slice(0, pathname.indexOf("/")); + if (index && index >= 0) pathname += "/" + index; + if (encoded) pathname += "/" + encodedDiagram; + if (oldPathname.slice(-1) === "/") pathname += "/"; + url.pathname = new URL(resolvePath(pathname)).pathname; + if (url.searchParams.get("url")) { + url.searchParams.set("url", encodedDiagram); + } + return { url, pathname }; +} + +function buildUrl(serletpath, encodedDiagram, index) { + let pathname = serletpath; + if (index && index >= 0) pathname += "/" + index; + pathname += "/" + encodedDiagram; + return pathname; +} diff --git a/src/main/webapp/min/plantuml-language.min.js b/src/main/webapp/min/plantuml-language.min.js new file mode 100644 index 0000000..1c86ba7 --- /dev/null +++ b/src/main/webapp/min/plantuml-language.min.js @@ -0,0 +1,45 @@ +'use strict';var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.arrayIteratorImpl=function(a){var b=0;return function(){return b>>0,$jscomp.propertyToPolyfillSymbol[e]=$jscomp.IS_SYMBOL_NATIVE? +$jscomp.global.Symbol(e):$jscomp.POLYFILL_PREFIX+d+"$"+e),$jscomp.defineProperty(c,$jscomp.propertyToPolyfillSymbol[e],{configurable:!0,writable:!0,value:b})))};$jscomp.underscoreProtoCanBeSet=function(){var a={a:!0},b={};try{return b.__proto__=a,b.a}catch(d){}return!1}; +$jscomp.setPrototypeOf=$jscomp.TRUST_ES6_POLYFILLS&&"function"==typeof Object.setPrototypeOf?Object.setPrototypeOf:$jscomp.underscoreProtoCanBeSet()?function(a,b){a.__proto__=b;if(a.__proto__!==b)throw new TypeError(a+" is not extensible");return a}:null;$jscomp.generator={};$jscomp.generator.ensureIteratorResultIsObject_=function(a){if(!(a instanceof Object))throw new TypeError("Iterator result "+a+" is not an object");}; +$jscomp.generator.Context=function(){this.isRunning_=!1;this.yieldAllIterator_=null;this.yieldResult=void 0;this.nextAddress=1;this.finallyAddress_=this.catchAddress_=0;this.finallyContexts_=this.abruptCompletion_=null};$jscomp.generator.Context.prototype.start_=function(){if(this.isRunning_)throw new TypeError("Generator is already running");this.isRunning_=!0};$jscomp.generator.Context.prototype.stop_=function(){this.isRunning_=!1}; +$jscomp.generator.Context.prototype.jumpToErrorHandler_=function(){this.nextAddress=this.catchAddress_||this.finallyAddress_};$jscomp.generator.Context.prototype.next_=function(a){this.yieldResult=a};$jscomp.generator.Context.prototype.throw_=function(a){this.abruptCompletion_={exception:a,isException:!0};this.jumpToErrorHandler_()};$jscomp.generator.Context.prototype.return=function(a){this.abruptCompletion_={return:a};this.nextAddress=this.finallyAddress_}; +$jscomp.generator.Context.prototype.jumpThroughFinallyBlocks=function(a){this.abruptCompletion_={jumpTo:a};this.nextAddress=this.finallyAddress_};$jscomp.generator.Context.prototype.yield=function(a,b){this.nextAddress=b;return{value:a}};$jscomp.generator.Context.prototype.yieldAll=function(a,b){a=$jscomp.makeIterator(a);var d=a.next();$jscomp.generator.ensureIteratorResultIsObject_(d);if(d.done)this.yieldResult=d.value,this.nextAddress=b;else return this.yieldAllIterator_=a,this.yield(d.value,b)}; +$jscomp.generator.Context.prototype.jumpTo=function(a){this.nextAddress=a};$jscomp.generator.Context.prototype.jumpToEnd=function(){this.nextAddress=0};$jscomp.generator.Context.prototype.setCatchFinallyBlocks=function(a,b){this.catchAddress_=a;void 0!=b&&(this.finallyAddress_=b)};$jscomp.generator.Context.prototype.setFinallyBlock=function(a){this.catchAddress_=0;this.finallyAddress_=a||0};$jscomp.generator.Context.prototype.leaveTryBlock=function(a,b){this.nextAddress=a;this.catchAddress_=b||0}; +$jscomp.generator.Context.prototype.enterCatchBlock=function(a){this.catchAddress_=a||0;a=this.abruptCompletion_.exception;this.abruptCompletion_=null;return a};$jscomp.generator.Context.prototype.enterFinallyBlock=function(a,b,d){d?this.finallyContexts_[d]=this.abruptCompletion_:this.finallyContexts_=[this.abruptCompletion_];this.catchAddress_=a||0;this.finallyAddress_=b||0}; +$jscomp.generator.Context.prototype.leaveFinallyBlock=function(a,b){b=this.finallyContexts_.splice(b||0)[0];if(b=this.abruptCompletion_=this.abruptCompletion_||b){if(b.isException)return this.jumpToErrorHandler_();void 0!=b.jumpTo&&this.finallyAddress_=k.status?f("json"===g?k.response:k.responseText): +l("json"===g?{status:k.status,response:k.response}:{status:k.status,responseText:k.responseText}))};k.open(a,h,!0);k.responseType=g;e&&Object.keys(e).forEach(function(m){return k.setRequestHeader(m,e[m])});k.send(c)})}; +PlantUmlLanguageFeatures.absolutePath=function(a){if(a.startsWith("http"))return a;if(a.startsWith("//"))return window.location.protocol+a;if(a.startsWith("/"))return window.location.origin+a;"./"==a.slice(0,2)&&(a=a.slice(2));var b=(document.querySelector("base")||{}).href||window.location.origin;"/"==b.slice(-1)&&(b=b.slice(0,-1));return b+"/"+a}; +(function(){var a={};PlantUmlLanguageFeatures.prototype.addValidationEventListener=function(b,d){if(!["before","code","line","after"].includes(b))throw Error("Unknown validation event type: "+b);a[b]=a[b]||[];a[b].push(d)};PlantUmlLanguageFeatures.prototype.validateCode=function(b){var d,c,e,g,h,f,l;return $jscomp.asyncExecutePromiseGeneratorProgram(function(k){d=[];d.push(null==(c=a.before)?void 0:c.map(function(m){return m({model:b})}));d.push(null==(e=a.code)?void 0:e.map(function(m){return m({model:b, +code:b.getValue()})}));if(a.line&&0]*)$/))?f.yield(b(a.getWordRange(d,c),g[1]),3):f.jumpTo(2)):2!=f.nextAddress?(h=f.yieldResult,f.return({suggestions:h})):f.return({suggestions:[]})})}})}; +PlantUmlLanguageFeatures.prototype.getIcons=function(){var a=void 0;return function(){return $jscomp.asyncExecutePromiseGeneratorProgram(function(b){if(1==b.nextAddress)return void 0!==a?b.jumpTo(2):b.yield(PlantUmlLanguageFeatures.makeRequest("GET","ui-helper?request\x3dicons"),3);2!=b.nextAddress&&(a=b.yieldResult);return b.return(a)})}}(); +PlantUmlLanguageFeatures.prototype.registerIconCompletion=function(){var a=this,b=function(d,c){var e,g;return $jscomp.asyncExecutePromiseGeneratorProgram(function(h){if(1==h.nextAddress)return h.yield(a.getIcons(),2);e=h.yieldResult;return h.return((null==(g=e)?void 0:g.filter(function(f){return c?f.includes(c):!0}).map(function(f){var l=PlantUmlLanguageFeatures.absolutePath(PlantUmlLanguageFeatures.baseUrl+"ui-helper?request\x3dicons.svg#"+f);return{label:f,kind:monaco.languages.CompletionItemKind.Constant, +documentation:{value:"![icon]("+l+") \x26nbsp; "+f},insertText:f+"\x3e",range:d}}))||[])})};monaco.languages.registerCompletionItemProvider(PlantUmlLanguageFeatures.languageSelector,{triggerCharacters:["\x26"],provideCompletionItems:function(d,c){var e,g,h;return $jscomp.asyncExecutePromiseGeneratorProgram(function(f){return 1==f.nextAddress?(e=d.getValueInRange({startLineNumber:c.lineNumber,startColumn:1,endLineNumber:c.lineNumber,endColumn:c.column}),(g=e.match(/<&([^\s>]*)$/))?f.yield(b(a.getWordRange(d, +c),g[1]),3):f.jumpTo(2)):2!=f.nextAddress?(h=f.yieldResult,f.return({suggestions:h})):f.return({suggestions:[]})})}})};PlantUmlLanguageFeatures.prototype.getThemes=function(){var a=void 0;return function(){return $jscomp.asyncExecutePromiseGeneratorProgram(function(b){if(1==b.nextAddress)return void 0!==a?b.jumpTo(2):b.yield(PlantUmlLanguageFeatures.makeRequest("GET","ui-helper?request\x3dthemes"),3);2!=b.nextAddress&&(a=b.yieldResult);return b.return(a)})}}(); +PlantUmlLanguageFeatures.prototype.registerThemeCompletion=function(){var a=this,b=function(d,c){var e,g;return $jscomp.asyncExecutePromiseGeneratorProgram(function(h){if(1==h.nextAddress)return h.yield(a.getThemes(),2);e=h.yieldResult;return h.return((null==(g=e)?void 0:g.filter(function(f){return c?f.includes(c):!0}).map(function(f){return{label:f,kind:monaco.languages.CompletionItemKind.Text,documentation:"PlantUML "+f+" theme",insertText:f,range:d}}))||[])})};monaco.languages.registerCompletionItemProvider(PlantUmlLanguageFeatures.languageSelector, +{triggerCharacters:[" "],provideCompletionItems:function(d,c){var e,g,h;return $jscomp.asyncExecutePromiseGeneratorProgram(function(f){return 1==f.nextAddress?(e=d.getValueInRange({startLineNumber:c.lineNumber,startColumn:1,endLineNumber:c.lineNumber,endColumn:c.column}),e.match(/^\s*!(t(h(e(m(e)?)?)?)?)?$/)?f.return({suggestions:[{label:"theme",kind:monaco.languages.CompletionItemKind.Keyword,documentation:"PlantUML theme command",insertText:"theme",range:a.getWordRange(d,c)}]}):(g=e.match(/^\s*!theme\s+([^\s]*)$/))? +f.yield(b(a.getWordRange(d,c),g[1]),3):f.jumpTo(2)):2!=f.nextAddress?(h=f.yieldResult,f.return({suggestions:h})):f.return({suggestions:[]})})}})};PlantUmlLanguageFeatures.prototype.getWordRange=function(a,b){a=a.getWordUntilPosition(b);return{startLineNumber:b.lineNumber,endLineNumber:b.lineNumber,startColumn:a.startColumn,endColumn:a.endColumn}}; +PlantUmlLanguageFeatures.prototype.addStartEndValidationListeners=function(){var a=void 0,b=0,d=0;this.addValidationEventListener("before",function(){a=void 0;d=b=0});this.addValidationEventListener("code",function(c){var e=c.model;if(c=c.code.match(/^(?:(?:'.*)|\s)*@start(\w+)/))a=c[1];else return{message:"PlantUML diagrams should begin with the `@start` command and `@start` should also be the first command.",severity:monaco.MarkerSeverity.Warning,startLineNumber:1,startColumn:1,endLineNumber:1, +endColumn:e.getLineLength(1)+1}});this.addValidationEventListener("code",function(c){var e=c.model,g=c.code;c=e.getLineCount();return(g=g.match(/\s+@end(\w+)(?:(?:'.*)|\s)*$/))?a===g[1]?void 0:{message:"PlantUML diagrams should start and end with the type.\nExample: `@startjson ... @endjson`",severity:monaco.MarkerSeverity.Error,startLineNumber:c,startColumn:1,endLineNumber:c,endColumn:e.getLineLength(c)+1}:{message:"PlantUML diagrams should end with the `@end` command and `@end` should also be the last command.", +severity:monaco.MarkerSeverity.Warning,startLineNumber:c,startColumn:1,endLineNumber:c,endColumn:e.getLineLength(c)+1}});this.addValidationEventListener("line",function(c){var e=c.range;c=c.line;var g=c.match(/^\s*@start(\w+)(?:\s+.*)?$/);if(g&&(b+=1,1.hr{padding:0 1rem;width:initial;height:100%}.hr:after{content:"";display:block;background-color:var(--border-color);height:100%;width:100%;min-height:3px;min-width:3px}.wait{cursor:wait}.wait>*{pointer-events:none}.flex-columns{display:flex;flex-direction:row;flex-wrap:wrap}.flex-rows{display:flex;flex-direction:column}.flex-main{flex:1 1 1px;overflow:auto}.flex-columns>*,.flex-rows>*{flex-shrink:0}.header{margin-left:auto;margin-right:auto;text-align:center} +.main{margin:1% 5%;z-index:1}.main>div{margin:0 1.75%}.main>div:first-child{margin-left:0}.main>div:last-child{margin-right:0}@media screen and (max-width:900px){.main{display:block;overflow:inherit}.main>div{margin:1.75% 0}.main>div:first-child{margin-top:0}.main>div:last-child{margin-bottom:0}}.footer p{background-color:var(--footer-bg-color);color:var(--footer-font-color);font-size:.7em;margin:0;padding:.5em;text-align:center}[data-theme="dark"] img:not(#diagram-png):not(.no-filter){filter:invert() contrast(30%)} +[data-theme="dark"] input[type="image"]{filter:invert() contrast(30%)}[data-theme="dark"] a{color:white}.editor{border:3px solid var(--border-color);box-sizing:border-box;overflow:hidden}@media screen and (max-width:900px){.editor{height:20em}}.editor .monaco-editor-container{overflow:hidden;position:relative}#monaco-editor{height:100%}#monaco-editor .overlayWidgets .suggest-details p img[alt="icon"],#monaco-editor .overlayWidgets .suggest-details p img[alt="emoji"]{height:1.2rem}.monaco-editor-container .editor-menu{position:absolute;right:0;top:0;display:flex;flex-direction:column;justify-content:center;align-items:center;flex:1}.monaco-editor-container .editor-menu>div.menu-kebab{width:60px;height:60px;display:flex;flex-wrap:wrap;justify-content:center;align-items:center;cursor:pointer;scale:.5}.monaco-editor-container .editor-menu:hover>div.menu-kebab,.monaco-editor-container .editor-menu:focus>div.menu-kebab{outline:0;scale:.65}.monaco-editor-container .menu-kebab .kebab-circle{width:12px;height:12px;margin:3px;background:var(--font-color);border-radius:50%;display:block;opacity:.8} +.monaco-editor-container .menu-kebab{flex-direction:column;position:relative;transition:all 300ms cubic-bezier(0.175,0.885,0.32,1.275)}.monaco-editor-container .menu-kebab .kebab-circle:nth-child(4),.monaco-editor-container .menu-kebab .kebab-circle:nth-child(5){position:absolute;opacity:0;top:50%;margin-top:-6px;left:50%}.monaco-editor-container .menu-kebab .kebab-circle:nth-child(4){margin-left:-25px}.monaco-editor-container .menu-kebab .kebab-circle:nth-child(5){margin-left:13px}.monaco-editor-container .editor-menu:hover .menu-kebab,.monaco-editor-container .editor-menu:focus .menu-kebab{transform:rotate(45deg)} +.monaco-editor-container .editor-menu:hover .menu-kebab .kebab-circle,.monaco-editor-container .editor-menu:focus .menu-kebab .kebab-circle{opacity:1}.monaco-editor-container .editor-menu .menu-item{display:none;margin:1rem 0;height:1.75rem;opacity:.5;position:relative;-webkit-animation-name:editor-menu-animateitem;-webkit-animation-duration:.4s;animation-name:editor-menu-animateitem;animation-duration:.4s}@-webkit-keyframes editor-menu-animateitem{from{top:-50%;opacity:0}to{top:0;opacity:.5}}@keyframes editor-menu-animateitem{from{top:-50%;opacity:0} +to{top:0;opacity:.5}}.monaco-editor-container .editor-menu .menu-item:hover{opacity:1}.monaco-editor-container .editor-menu:hover .menu-item,.monaco-editor-container .editor-menu:focus .menu-item{display:block}.editor .btn-input{align-items:center;border-bottom:3px solid var(--border-color);box-sizing:border-box;display:flex;justify-content:center}.editor .btn-input input[type=text]{border:0;flex:1 1 1px;font-family:monospace;font-size:medium;padding:.2em;text-overflow:ellipsis}.editor .btn-input input[type=text]:focus{border:0;box-shadow:none;outline:0}.editor .btn-input input[type="image"]{height:1rem;margin-left:.7em;padding:0 .3em}#diagram-export.modal .label-input-pair label{min-width:8rem}#diagram-import p.error-message{color:darkred;padding-left:1rem;padding-right:1rem}#diagram-import input[type="file"]{display:block;width:100%;border:.2rem dashed var(--border-color);border-radius:.4rem;box-sizing:border-box;padding:5rem 2rem}#diagram-import input[type="file"],#diagram-import input[type="file"]::file-selector-button{background-color:var(--modal-bg-color)}#diagram-import input[type="file"]:hover,#diagram-import input[type="file"].drop-able{border-color:var(--border-color-2);background-color:var(--file-drop-color)} +#diagram-import input[type="file"]:hover::file-selector-button,#diagram-import input[type="file"].drop-able::file-selector-button{background-color:var(--file-drop-color)}.modal{display:block;position:fixed;z-index:1;padding:5%;left:0;top:0;bottom:0;right:0;overflow:auto;background-color:#000;background-color:rgba(0,0,0,0.4)}.modal .modal-content{background-color:var(--modal-bg-color);margin:auto;padding:2rem;border:3px solid var(--border-color);max-width:30rem;box-shadow:0 4px 8px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19);-webkit-animation-name:modal-animatetop;-webkit-animation-duration:.4s;animation-name:modal-animatetop;animation-duration:.4s;position:relative;top:50%;transform:translateY(-50%)} +@-webkit-keyframes modal-animatetop{from{top:-50%;opacity:0}to{top:50%;opacity:1}}@keyframes modal-animatetop{from{top:-50%;opacity:0}to{top:50%;opacity:1}}.modal .modal-header h2{margin:0}.modal .modal-main{flex:1}.modal .modal-footer{margin-top:1rem;text-align:right}.modal input,.modal select{border:1px solid var(--border-color)}.modal input:not(:focus):invalid{border-bottom-color:red}.modal input[type="file"]::file-selector-button{border:1px solid var(--border-color)} +.modal input.ok,.modal input.cancel{min-width:5rem}.modal input.ok[disabled],.modal input.cancel[disabled]{color:var(--font-color-disabled)}.modal input.ok:not([disabled]):hover{border-bottom-color:green}.modal input.cancel:not([disabled]):hover{border-bottom-color:darkred}.modal .label-input-pair{margin:1rem 0;overflow:hidden}.modal .label-input-pair:first-child{margin-top:0}.modal .label-input-pair:last-child{margin-bottom:0}.modal .label-input-pair label{display:inline-block;min-width:15rem}.modal .label-input-pair label+input,.modal .label-input-pair label+select{box-sizing:border-box;display:inline-block;min-width:10rem}#settings #settings-monaco-editor{height:17rem;border:1px solid var(--border-color)}.diagram{height:100%;overflow:auto}.diagram[data-diagram-type="pdf"]{overflow:hidden}.diagram>div{margin:1rem 0;text-align:center}.diagram[data-diagram-type="pdf"]>div{height:20em;width:100%}.diagram img,.diagram svg,.diagram pre{border:3px solid var(--border-color);box-sizing:border-box;padding:10px}@media screen and (min-width:900px){.diagram{position:relative}.diagram>div{margin:0}.diagram:not([data-diagram-type="pdf"])>div{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);max-height:100%;max-width:100%} +.diagram[data-diagram-type="pdf"]>div{height:100%}}.preview-menu{margin-left:5%;margin-right:5%}.diagram-link img,.btn-dock{width:2.5rem}.btn-settings{width:2.2rem;margin-left:auto;margin-right:.25rem}.menu-r{min-width:3rem}.menu-r .btn-float-r{float:right;margin-left:.25rem;text-align:right}.diagram-links{align-items:center;display:flex}.diagram-link{margin-left:.25rem;margin-right:.25rem}.diagram-links .diagram-link:first-of-type{margin-left:.5rem}.diagram-links .diagram-link:last-of-type{margin-right:0}#paginator{text-align:center;margin-bottom:1rem}.previewer-container{height:100%}@media screen and (max-width:900px){.previewer-container{height:initial}.previewer-main{flex:none}} \ No newline at end of file diff --git a/src/main/webapp/min/plantuml.min.js b/src/main/webapp/min/plantuml.min.js new file mode 100644 index 0000000..0506661 --- /dev/null +++ b/src/main/webapp/min/plantuml.min.js @@ -0,0 +1,77 @@ +'use strict';var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.arrayIteratorImpl=function(a){var b=0;return function(){return b>>0,$jscomp.propertyToPolyfillSymbol[e]=$jscomp.IS_SYMBOL_NATIVE? +$jscomp.global.Symbol(e):$jscomp.POLYFILL_PREFIX+c+"$"+e),$jscomp.defineProperty(d,$jscomp.propertyToPolyfillSymbol[e],{configurable:!0,writable:!0,value:b})))};$jscomp.assign=$jscomp.TRUST_ES6_POLYFILLS&&"function"==typeof Object.assign?Object.assign:function(a,b){for(var c=1;c=r&&(u=r-1);makeRequest("POST","coder",{data:t}).then(function(v){sendMessage({sender:p,data:{encodedDiagram:v,numberOfDiagramPages:r,index:u},synchronize:!0})})}k=void 0===k?!0:k;var q=function(){return function(){var r=document.editor.getModel();m=m||new PlantUmlLanguageFeatures; +m.validateCode(r).then(function(u){return monaco.editor.setModelMarkers(r,"plantuml",u)})}}();p&&k&&n();q()}var m,l=monaco.editor.createModel(function(){var t=document.getElementById("initCode"),p=t.value;t.remove();return p}(),"apex",monaco.Uri.parse("inmemory://plantuml")),h=0;l.onDidChangeContent(function(){clearTimeout(h);document.appConfig.autoRefreshState="waiting";h=setTimeout(function(){return g(l.getValue(),"editor")},document.appConfig.editorWatcherTimeout)});return l}function d(){return{get:function(){}, +getBoolean:function(g){return"expandSuggestionDocs"===g},getNumber:function(){return 0},remove:function(){},store:function(){},onWillSaveState:function(){},onDidChangeStorage:function(){},onDidChangeValue:function(){}}}var e,f;return $jscomp.asyncExecutePromiseGeneratorProgram(function(g){if(1==g.nextAddress)return g.yield(b(),2);"previewer"!==a&&(e=c(),f=d(),document.editor=monaco.editor.create(document.getElementById("monaco-editor"),Object.assign({},{model:e},document.appConfig.editorCreateOptions), +{storageService:f}),document.addEventListener("resize",function(){return document.editor.layout()}),initEditorUrlInput(),initEditorMenu());g.jumpToEnd()})}}}(),setEditorValue=$jscomp$destructuring$var0.setEditorValue,initEditor=$jscomp$destructuring$var0.initEditor; +function initEditorMenu(){document.getElementById("menu-item-editor-code-copy").addEventListener("click",function(){var a=document.editor.getModel().getFullModelRange();document.editor.focus();document.editor.setSelection(a);a=document.editor.getValue();var b;null==(b=navigator.clipboard)||b.writeText(a).catch(function(){})})} +var $jscomp$destructuring$var3=function(){function a(b,c,d){var e=void 0===c?{}:c;c=void 0===e.encodedDiagram?void 0:e.encodedDiagram;e=void 0===e.index?void 0:e.index;d=void 0===d?{}:d;if(b||c)(void 0===d.suppressEditorChangedMessage?0:d.suppressEditorChangedMessage)&&suppressNextMessage("url"),document.getElementById("url").value=b?b:resolvePath(buildUrl("png",c,e))}return{setUrlValue:a,initEditorUrlInput:function(){var b=document.getElementById("url");a(resolvePath(b.value));b.addEventListener("change", +function(c){var d,e;return $jscomp.asyncExecutePromiseGeneratorProgram(function(f){if(1==f.nextAddress)return document.appConfig.autoRefreshState="started",c.target.title=c.target.value,d=analyseUrl(c.target.value),f.yield(makeRequest("GET","coder/"+d.encodedDiagram),2);e=f.yieldResult;setEditorValue(document.editor,e,{suppressEditorChangedMessage:!0});sendMessage({sender:"url",data:{encodedDiagram:d.encodedDiagram,index:d.index},synchronize:!0});f.jumpToEnd()})});document.getElementById("url-copy-btn").addEventListener("click", +function(){b.focus();b.select();var c;null==(c=navigator.clipboard)||c.writeText(b.value).catch(function(){})})}}}(),setUrlValue=$jscomp$destructuring$var3.setUrlValue,initEditorUrlInput=$jscomp$destructuring$var3.initEditorUrlInput; +function initDiagramExport(){function a(){setVisibility(document.getElementById("diagram-export"),!0,!0);var f=document.editor.getValue();f=Array.from(f.matchAll(/^\s*@start[a-zA-Z]*\s+([a-zA-Z-_\u00e4\u00f6\u00fc\u00c4\u00d6\u00dc\u00df ]+)\s*$/gm),function(g){return g[1]})[0]||"diagram";d.value=f+".puml";e.value="code";d.focus()}function b(f){var g=f.lastIndexOf(".");return 1>g?{name:f,ext:null}:g===f.length-1?{name:f.slice(0,-1),ext:null}:{name:f.substring(0,g),ext:f.substring(g+1)}}function c(f){if(!f)return f; +f=f.toLowerCase();switch(f){case "puml":case "plantuml":case "code":return"code";case "ascii":return"txt";default:return f}}var d=document.getElementById("download-name"),e=document.getElementById("download-type");registerModalListener("diagram-export",a);d.addEventListener("change",function(f){f=b(f.target.value).ext;if(f=c(f))e.value=f});e.addEventListener("change",function(f){f=f.target.value;a:switch(f){case "epstext":f="eps";break a;case "code":f="puml"}var g=b(d.value).name;d.value=g+"."+f}); +document.getElementById("diagram-export-ok-btn").addEventListener("click",function(){var f=d.value,g=e.value,m=document.createElement("a");m.download=f;"code"===g?(f=document.editor.getValue(),m.href="data:,"+encodeURIComponent(f)):m.href=void 0!==document.appData.index?g+"/"+document.appData.index+"/"+document.appData.encodedDiagram:g+"/"+document.appData.encodedDiagram;m.click()});window.addEventListener("keydown",function(f){"s"===f.key&&(isMac?f.metaKey:f.ctrlKey)&&(f.preventDefault(),isModalOpen("diagram-export")|| +a())},!1)} +function initDiagramImport(){function a(h){h=void 0===h?!0:h;setVisibility(f,!0,!0);f.dataset.isOpenManually=h.toString();g.value="";c(g)}function b(){g.value="";c(g);f.removeAttribute("data-is-open-manually");setVisibility(f,!1)}function c(h){l.innerText="";var t;m.disabled=1>(null==(t=h.files)?void 0:t.length)}function d(h){function t(k){var n=k.name,q=k.type;k=["plain","text","plantuml","puml"];if(0p.length)return t();p=p[0];var k=d(p);if(!k.valid)return t();"true"!==f.dataset.isOpenManually&&(t(),e(p,k))},!1);m.addEventListener("click",function(){var h=g.files[0];e(h,d(h))});registerModalListener("diagram-import",a,b)} +var $jscomp$destructuring$var16=function(){var a={};return{registerModalListener:function(b,c,d){a[b]={fnOpen:c,fnClose:d}},openModal:function(b){var c=$jscomp.getRestArguments.apply(1,arguments),d,e=null==(d=a[b])?void 0:d.fnOpen;e?e.apply(null,$jscomp.arrayFromIterable(c)):setVisibility(document.getElementById(b),!0,!0)},closeModal:function(b){var c=$jscomp.getRestArguments.apply(1,arguments),d,e=null==(d=a[b])?void 0:d.fnClose;e?e.apply(null,$jscomp.arrayFromIterable(c)):setVisibility(document.getElementById(b), +!1)}}}(),registerModalListener=$jscomp$destructuring$var16.registerModalListener,openModal=$jscomp$destructuring$var16.openModal,closeModal=$jscomp$destructuring$var16.closeModal; +function initModals(a){function b(c){"Escape"===c.key||"Esc"===c.key?(c.preventDefault(),closeModal(c.target.closest(".modal").id)):"Enter"===c.key&&(c.preventDefault(),(c=modal.querySelector('input.ok[type\x3d"button"]'))&&!c.disabled&&c.click())}document.querySelectorAll(".modal").forEach(function(c){c.addEventListener("keydown",b,!1)});initSettings();"previewer"!==a&&(initDiagramExport(),initDiagramImport())}function isModalOpen(a){return isVisible(document.getElementById(a))} +function closeAllModals(){document.querySelectorAll(".modal").forEach(function(a){return closeModal(a.id)})} +function initSettings(){function a(){setVisibility(document.getElementById("settings"),!0,!0);b.value=document.appConfig.theme;c.value=document.appConfig.diagramPreviewType;d.value=document.appConfig.editorWatcherTimeout;setEditorValue(document.settingsEditor,JSON.stringify(document.appConfig.editorCreateOptions,null," "))}var b=document.getElementById("theme"),c=document.getElementById("diagramPreviewType"),d=document.getElementById("editorWatcherTimeout");document.settingsEditor=monaco.editor.create(document.getElementById("settings-monaco-editor"), +Object.assign({},{language:"json"},document.appConfig.editorCreateOptions));b.addEventListener("change",function(e){e=e.target.value;var f=document.settingsEditor.getValue();setEditorValue(document.settingsEditor,f.replace(new RegExp('("theme"\\s*:\\s*)"'+("dark"===e?"vs":"vs-dark")+'"',"gm"),'$1"'+("dark"===e?"vs-dark":"vs")+'"'))});document.getElementById("settings-ok-btn").addEventListener("click",function(){var e=Object.assign({},document.appConfig);e.theme=b.value;e.editorWatcherTimeout=d.value; +e.diagramPreviewType=c.value;e.editorCreateOptions=JSON.parse(document.settingsEditor.getValue());updateConfig(e);closeModal("settings")});window.addEventListener("keydown",function(e){","===e.key&&(isMac?e.metaKey:e.ctrlKey)&&(e.preventDefault(),isModalOpen("settings")||a())},!1);registerModalListener("settings",a)} +function initializeDiagram(){return $jscomp.asyncExecutePromiseGeneratorProgram(function(a){if("png"!==document.appConfig.diagramPreviewType)return a.return(setDiagram(document.appConfig.diagramPreviewType,document.appData.encodedDiagram,document.appData.index));a.jumpToEnd()})} +function setDiagram(a,b,c){function d(k,n,q){return $jscomp.asyncExecutePromiseGeneratorProgram(function(r){return r.return(makeRequest("GET",buildUrl(k,n,q)))})}var e,f,g,m,l,h,t,p;return $jscomp.asyncExecutePromiseGeneratorProgram(function(k){switch(k.nextAddress){case 1:e=document.getElementById("diagram");f=document.getElementById("diagram-png");g=document.getElementById("diagram-txt");m=document.getElementById("diagram-pdf");if("png"===a)return f.src=buildUrl("png",b,c),k.yield(d("map",b,c), +9);if("svg"===a)return k.yield(d("svg",b,c),8);if("txt"!==a){if("pdf"===a)m.data=buildUrl("pdf",b,c);else return l="unknown diagram type: "+a,(console.error||console.log)(l),k.return(Promise.reject(l));k.jumpTo(3);break}h=g;return k.yield(d("txt",b,c),7);case 7:h.innerHTML=k.yieldResult;k.jumpTo(3);break;case 8:t=k.yieldResult;var n=document.getElementById("diagram-svg"),q=document.createElement("div");q.innerHTML=t;q=q.querySelector("svg");q.id="diagram-svg";q.classList=n.classList;q.style.cssText= +n.style.cssText;n.parentNode.replaceChild(q,n);k.jumpTo(3);break;case 9:if(p=k.yieldResult,n=document.getElementById("plantuml_map"),q=document.getElementById("map-diagram-link"),p){var r=document.createElement("div");r.innerHTML=p;n.parentNode.replaceChild(r.firstChild,n);setVisibility(q,!0)}else removeChildren(n),setVisibility(q,!1);case 3:n=document.getElementById("plantuml_map"),q=document.getElementById("diagram-svg"),e.setAttribute("data-diagram-type",a),setVisibility(f,"png"===a),setVisibility(n, +"png"===a),setVisibility(q,"svg"===a),setVisibility(g,"txt"===a),setVisibility(m,"pdf"===a),k.jumpToEnd()}})}function getNumberOfDiagramPagesFromCode(a){var b;return(null==(b=a.match(/^\s*newpage\s?.*$/gm))?void 0:b.length)+1||1}function updatePaginatorSelection(){var a=document.getElementById("paginator"),b=document.appData.index;if(void 0===b||a.childNodes.length<=b)for(a=$jscomp.makeIterator(a.childNodes),b=a.next();!b.done;b=a.next())b.value.checked=!1;else a.childNodes[b].checked=!0} +var updatePaginator=function(){function a(b,c){for(;b.childElementCount>c;)b.removeChild(b.lastChild);for(;b.childElementCount .hr { - padding: 0 1rem; - width: initial; - height: 100%; -} -.hr:after { - content: ""; - display: block; - background-color: var(--border-color); - height: 100%; - width: 100%; - min-height: 3px; - min-width: 3px; -} - -/************* wait cursor *************/ -.wait { - cursor: wait; -} -.wait > * { - pointer-events: none; -} - -/************* flex rows and columns *************/ -.flex-columns { - display: flex; - flex-direction: row; - flex-wrap: wrap; -} -.flex-rows { - display: flex; - flex-direction: column; -} -.flex-main { - flex: 1 1 1px; - overflow: auto; -} -.flex-columns > *, .flex-rows > * { - flex-shrink: 0; -} - -/*******************************************************************/ -/************* header, main, footer *************/ -.header { - margin-left: auto; - margin-right: auto; - text-align: center; -} -.main { - margin: 1% 5%; - z-index: 1; -} -.main > div { - margin: 0 1.75%; -} -.main > div:first-child { - margin-left: 0; -} -.main > div:last-child { - margin-right: 0; -} -@media screen and (max-width: 900px) { - .main { - display: block; - overflow: inherit; - } - .main > div { - margin: 1.75% 0; - } - .main > div:first-child { - margin-top: 0; - } - .main > div:last-child { - margin-bottom: 0; - } -} -.footer p { - background-color: var(--footer-bg-color); - color: var(--footer-font-color); - font-size: 0.7em; - margin: 0; - padding: 0.5em; - text-align: center; -} - -/*******************************************************************/ -/************* editor *************/ -.editor { - border: 3px solid var(--border-color); - box-sizing: border-box; - overflow: hidden; -} -@media screen and (max-width: 900px) { - .editor { - height: 20em; - } -} -.monaco-editor-container { - overflow: hidden; - position: relative; -} -#monaco-editor { - height: 100%; -} -/* Hack to display the icons and emojis in the auto completion documentation in a visible size. - * (see PlantUmlLanguageFeatures.register{Icon,Emoji}Completion) */ -#monaco-editor .overlayWidgets .suggest-details p img[alt="icon"], -#monaco-editor .overlayWidgets .suggest-details p img[alt="emoji"] { - height: 1.2rem; -} - -/************* URL input + copy button *************/ -.btn-input { - align-items: center; - border-bottom: 3px solid var(--border-color); - box-sizing: border-box; - display: flex; - justify-content: center; -} -.btn-input input[type=text] { - border: 0; - flex: 1 1 1px; - font-family: monospace; - font-size: medium; - padding: 0.2em; - text-overflow: ellipsis; -} -.btn-input input[type=text]:focus { - border: 0; - box-shadow: none; - outline: none; -} -.btn-input input[type="image"] { - height: 1rem; - margin-left: 0.7em; - padding: 0 0.3em; -} - -/************* Monaco editor action menu *************/ -.monaco-editor-container .editor-menu { - position: absolute; - right: 0; - top: 0; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - flex: 1; -} -.monaco-editor-container .editor-menu > div.menu-kebab { - width: 60px; - height: 60px; - display: flex; - flex-wrap: wrap; - justify-content: center; - align-items: center; - cursor: pointer; - scale: 0.5; -} -.monaco-editor-container .editor-menu:hover > div.menu-kebab, -.monaco-editor-container .editor-menu:focus > div.menu-kebab { - outline: none; - scale: 0.65; -} -.monaco-editor-container .menu-kebab .kebab-circle { - width: 12px; - height: 12px; - margin: 3px; - background: var(--font-color); - border-radius: 50%; - display: block; - opacity: 0.8; -} -.monaco-editor-container .menu-kebab { - flex-direction: column; - position: relative; - transition: all 300ms cubic-bezier(0.175, 0.885, 0.32, 1.275); -} -.monaco-editor-container .menu-kebab .kebab-circle:nth-child(4), -.monaco-editor-container .menu-kebab .kebab-circle:nth-child(5) { - position: absolute; - opacity: 0; - top: 50%; - margin-top: -6px; - left: 50%; -} -.monaco-editor-container .menu-kebab .kebab-circle:nth-child(4) { - margin-left: -25px; -} -.monaco-editor-container .menu-kebab .kebab-circle:nth-child(5) { - margin-left: 13px; -} -.monaco-editor-container .editor-menu:hover .menu-kebab, -.monaco-editor-container .editor-menu:focus .menu-kebab { - transform: rotate(45deg); -} -.monaco-editor-container .editor-menu:hover .menu-kebab .kebab-circle, -.monaco-editor-container .editor-menu:focus .menu-kebab .kebab-circle { - opacity: 1; -} - -.monaco-editor-container .editor-menu .menu-item { - display: none; - margin: 1rem 0; - height: 1.75rem; - opacity: 0.5; - position: relative; - -webkit-animation-name: animateitem; - -webkit-animation-duration: 0.4s; - animation-name: animateitem; - animation-duration: 0.4s; -} -@-webkit-keyframes animateitem { - from { top: -50%; opacity: 0; } - to { top: 0; opacity: 0.5; } -} -@keyframes animateitem { - from { top: -50%; opacity: 0; } - to { top: 0; opacity: 0.5; } -} -.monaco-editor-container .editor-menu .menu-item:hover { - opacity: 1; -} -.monaco-editor-container .editor-menu:hover .menu-item, -.monaco-editor-container .editor-menu:focus .menu-item { - display: block; -} - -/*******************************************************************/ -/************* previewer *************/ -.content.viewer-content { - margin: 5%; -} -.content.viewer-content, .previewer-container { - height: 100%; -} -@media screen and (max-width: 900px) { - .previewer-container { - height: initial; - } - .previewer-main { - flex: none; - } -} - -/************* menu *************/ -.preview-menu { - margin-left: 5%; - margin-right: 5%; -} -.diagram-link img, .btn-dock { - width: 2.5rem; -} -.btn-settings { - width: 2.2rem; - margin-left: auto; - margin-right: 0.25rem; -} -.menu-r { - min-width: 3rem; -} -.menu-r .btn-float-r { - float: right; - margin-left: 0.25rem; - text-align: right; -} -.diagram-links { - align-items: center; - display: flex; -} -.diagram-link { - margin-left: 0.25rem; - margin-right: 0.25rem; -} -.diagram-links .diagram-link:first-of-type { - margin-left: 0.5rem; -} -.diagram-links .diagram-link:last-of-type { - margin-right: 0; -} - -/************* paginator *************/ -#paginator { - text-align: center; - margin-bottom: 1rem; -} - -/************* diagram *************/ -.diagram { - height: 100%; - overflow: auto; -} -.diagram[data-diagram-type="pdf"] { - overflow: hidden; -} -.diagram > div { - margin: 1rem 0; - text-align: center; -} -.diagram[data-diagram-type="pdf"] > div { - height: 20em; - width: 100%; -} -.diagram img, .diagram svg, .diagram pre { - border: 3px solid var(--border-color); - box-sizing: border-box; - padding: 10px; -} -@media screen and (min-width: 900px) { - .diagram { - position: relative; - } - .diagram > div { - margin: 0; - } - .diagram:not([data-diagram-type="pdf"]) > div { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - max-height: 100%; - max-width: 100%; - } - .diagram[data-diagram-type="pdf"] > div { - height: 100%; - } -} - -/*******************************************************************/ -/************* modal *************/ -.modal { - display: block; - position: fixed; - z-index: 1; - padding: 5%; - left: 0; - top: 0; - bottom: 0; - right: 0; - overflow: auto; - background-color: rgb(0, 0, 0); - background-color: rgba(0, 0, 0, 0.4); -} -.modal .modal-content { - background-color: var(--modal-bg-color); - margin: auto; - padding: 2rem; - border: 3px solid var(--border-color); - max-width: 30rem; - box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); - -webkit-animation-name: animatetop; - -webkit-animation-duration: 0.4s; - animation-name: animatetop; - animation-duration: 0.4s; - position: relative; - top: 50%; - transform: translateY(-50%); -} -@-webkit-keyframes animatetop { - from { top: -50%; opacity: 0; } - to { top: 50%; opacity: 1; } -} -@keyframes animatetop { - from { top: -50%; opacity: 0; } - to { top: 50%; opacity: 1; } -} -/************* header, main, footer *************/ -.modal .modal-header h2 { - margin: 0; -} -.modal .modal-main { - flex: 1; -} -.modal .modal-footer { - margin-top: 1rem; - text-align: right; -} -/************* inputs *************/ -.modal input, .modal select { - border: 1px solid var(--border-color); -} -.modal input:not(:focus):invalid { - border-bottom-color: red; -} -.modal input[type="file"]::file-selector-button { - border: 1px solid var(--border-color); -} -/************* ok + cancel buttons *************/ -.modal input.ok, .modal input.cancel { - min-width: 5rem; -} -.modal input.ok[disabled], .modal input.cancel[disabled] { - color: var(--font-color-disabled); -} -.modal input.ok:not([disabled]):hover { - border-bottom-color: green; -} -.modal input.cancel:not([disabled]):hover { - border-bottom-color: darkred; -} -/************* label + input pair *************/ -.modal .label-input-pair { - margin: 1rem 0; - overflow: hidden; -} -.modal .label-input-pair:first-child { - margin-top: 0; -} -.modal .label-input-pair:last-child { - margin-bottom: 0; -} -.modal .label-input-pair label { - display: inline-block; - min-width: 15rem; -} -.modal .label-input-pair label + input, -.modal .label-input-pair label + select { - box-sizing: border-box; - display: inline-block; - min-width: 10rem; -} - -/************* settings *************/ -#settings #settings-monaco-editor { - height: 17rem; - border: 1px solid var(--border-color); -} -/************* diagram import *************/ -#diagram-import p.error-message { - color: darkred; - padding-left: 1rem; - padding-right: 1rem; -} -#diagram-import input[type="file"] { - display: block; - width: 100%; - border: 0.2rem dashed var(--border-color); - border-radius: 0.4rem; - box-sizing: border-box; - padding: 5rem 2rem; -} -#diagram-import input[type="file"], -#diagram-import input[type="file"]::file-selector-button { - background-color: var(--modal-bg-color); -} -#diagram-import input[type="file"]:hover, -#diagram-import input[type="file"].drop-able { - border-color: var(--border-color-2); - background-color: var(--file-drop-color); -} -#diagram-import input[type="file"]:hover::file-selector-button, -#diagram-import input[type="file"].drop-able::file-selector-button { - background-color: var(--file-drop-color); -} -/************* diagram export *************/ -#diagram-export.modal .label-input-pair label { - min-width: 8rem; -} - -/*******************************************************************/ -/************* color themes *************/ -[data-theme="dark"] img:not(#diagram-png):not(.no-filter) { - filter: invert() contrast(30%); -} -[data-theme="dark"] input[type="image"] { - filter: invert() contrast(30%); -} -[data-theme="dark"] a { - color: white; -} diff --git a/src/main/webapp/plantuml.js b/src/main/webapp/plantuml.js deleted file mode 100644 index d0fca75..0000000 --- a/src/main/webapp/plantuml.js +++ /dev/null @@ -1,1051 +0,0 @@ -/************************* -* PlantUMLServlet script * -**************************/ - -// ========================================================================================================== -// == global configuration == - -document.appConfig = Object.assign({}, window.opener?.document.appConfig); -if (Object.keys(document.appConfig).length === 0) { - document.appConfig = JSON.parse(localStorage.getItem("document.appConfig")) || { - changeEventsEnabled: true, - // `autoRefreshState` is mostly used for unit testing puposes. - // states: disabled | waiting | started | syncing | complete - autoRefreshState: "disabled", - theme: undefined, // dark | light (will be set via `initTheme` if undefined) - diagramPreviewType: "png", - editorWatcherTimeout: 500, - editorCreateOptions: { - automaticLayout: true, - fixedOverflowWidgets: true, - minimap: { enabled: false }, - scrollbar: { alwaysConsumeMouseWheel: false }, - scrollBeyondLastLine: false, - tabSize: 2, - theme: "vs", // "vs-dark" - } - }; -} - - -// ========================================================================================================== -// == DOM helpers == - -function removeChildren(el) { - if (el.replaceChildren) { - el.replaceChildren(); - } else { - el.innerHTML = ""; - } -} - -function isVisible(el) { - // `offsetParent` returns `null` if the element, or any of its parents, - // is hidden via the display style property. - // see: https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetParent - return (el.offsetParent !== null); -} - -function setVisibility(el, visibility, focus=false) { - if (visibility) { - el.style.removeProperty("display"); - if (focus) el.focus(); - } else { - el.style.display = "none"; - } -} - -const isMac = (function() { - const PLATFORM = navigator?.userAgentData?.platform || navigator?.platform || "unknown"; - return PLATFORM.match("Mac"); -})(); - - -// ========================================================================================================== -// == URL helpers == - -function resolvePath(path) { - // also see `PlantUmlLanguageFeatures.resolvePath(path)` - if (path.startsWith("http")) return path; - if (path.startsWith("/")) return window.location.origin + path; - - if (path.slice(0, 2) == "./") path = path.slice(2); - let base = (document.querySelector("base") || {}).href || window.location.origin; - if (base.slice(-1) == "/") base = base.slice(0, -1); - return base + "/" + path; -} - -function prepareUrl(url) { - if (!(url instanceof URL)) { - url = new URL(resolvePath(url)); - } - // pathname excluding context path - let base = new URL((document.querySelector("base") || {}).href || window.location.origin).pathname; - if (base.slice(-1) === "/") base = base.slice(0, -1); - const pathname = url.pathname.startsWith(base) ? url.pathname.slice(base.length) : url.pathname; - // same as `UrlDataExtractor.URL_PATTERN` - const regex = /\/\w+(?:\/(?\d+))?(?:\/(?[^\/]+))?\/?$/gm; - const match = regex.exec(pathname); - return [ url, pathname, match ]; -} - -function analyseUrl(url) { - let match; - [url, _, match] = prepareUrl(url); - return { - index: match.groups.idx, - encodedDiagram: match.groups.encoded || url.searchParams.get("url"), - }; -} - -function replaceUrl(url, encodedDiagram, index) { - let oldPathname, match; - [url, oldPathname, match] = prepareUrl(url); - let pathname = oldPathname.slice(1); - pathname = pathname.slice(0, pathname.indexOf("/")); - if (index && index >= 0) pathname += "/" + index; - if (match.groups.encoded) pathname += "/" + encodedDiagram; - if (oldPathname.slice(-1) === "/") pathname += "/"; - url.pathname = new URL(resolvePath(pathname)).pathname; - if (url.searchParams.get("url")) { - url.searchParams.set("url", encodedDiagram); - } - return { url, pathname }; -} - -function buildUrl(serletpath, encodedDiagram, index) { - let pathname = serletpath; - if (index && index >= 0) pathname += "/" + index; - pathname += "/" + encodedDiagram; - return pathname; -} - - -// ========================================================================================================== -// == clipboard helpers == - -function copyUrlToClipboard() { - const input = document.getElementById("url"); - input.focus(); - input.select(); - navigator.clipboard?.writeText(input.value).catch(() => {}); -} - -function copyCodeToClipboard() { - const range = document.editor.getModel().getFullModelRange(); - document.editor.focus(); - document.editor.setSelection(range); - const code = document.editor.getValue(); - navigator.clipboard?.writeText(code).catch(() => {}); -} - - -// ========================================================================================================== -// == theme helpers == - -function getBrowserThemePreferences() { - if (window.matchMedia("(prefers-color-scheme: dark)").matches) { - return "dark"; - } - if (window.matchMedia("(prefers-color-scheme: light)").matches) { - return "light"; - } - return undefined; -} - -function setTheme(theme) { - document.documentElement.setAttribute("data-theme", theme); -} - - -// ========================================================================================================== -// == asynchron server calls == - -function call(method, url, data, callback) { - const xhr = new XMLHttpRequest(); - xhr.onreadystatechange = function() { - if (xhr.readyState == 4 && xhr.status == 200) { - callback(xhr.responseText); - } - } - xhr.open(method, url, true); - xhr.setRequestHeader("Content-Type", "text/plain"); - xhr.send(data); -} - -function decodeDiagram(encodedDiagram, callback) { - call("GET", "coder/" + encodedDiagram, null, callback); -} - -function encodeDiagram(diagram, callback) { - call("POST", "coder", diagram, callback); -} - -function requestDiagram(type, encodedDiagram, index, callback) { - call("GET", buildUrl(type, encodedDiagram, index), null, callback); -} - -function requestDiagramMap(encodedDiagram, index, callback) { - requestDiagram("map", encodedDiagram, index, callback); -} - -function requestMetadata(file) { - return new Promise((resolve, reject) => { - const fd = new FormData(); - fd.append("diagram", file, file.name); - const xhr = new XMLHttpRequest(); - xhr.onreadystatechange = function() { - if (xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status >= 200 && xhr.status <= 300) { - resolve(xhr.response); - } else { - reject({ status: xhr.status, response: xhr.response }); - } - } - } - xhr.open("POST", "metadata", true); - xhr.setRequestHeader("Accept", "application/json"); - xhr.responseType = "json"; - xhr.send(fd); - }); -} - - -// ========================================================================================================== -// == modal == - -const { registerModalListener, openModal, closeModal } = (function() { - const modalListener = {}; - return { - registerModalListener: (id, fnOpen=undefined, fnClose=undefined) => modalListener[id] = { fnOpen, fnClose }, - openModal: (id, ...args) => { - modalListener[id]?.fnOpen?.call(...args) || setVisibility(document.getElementById(id), true, true) - }, - closeModal: (id, ...args) => { - modalListener[id]?.fnClose?.call(...args) || setVisibility(document.getElementById(id), false); - }, - }; -})(); - -function initModals() { - document.querySelectorAll(".modal").forEach(modal => { - modal.addEventListener("keydown", (event) => { - if (event.key === "Escape" || event.key === "Esc") { - event.preventDefault(); - closeModal(modal.id); - } else if (event.key === "Enter") { - event.preventDefault(); - const okBtn = modal.querySelector('input.ok[type="button"]'); - if (okBtn && !okBtn.disabled) { - okBtn.click(); - } - } - }, false); - }); -} - -function isModalOpen(id) { - return isVisible(document.getElementById(id)); -} - -function closeAllModals() { - document.querySelectorAll(".modal").forEach(modal => closeModal(modal.id)); -} - - -// ========================================================================================================== -// == settings == - -function initSettings() { - document.getElementById("theme").addEventListener("change", (event) => { - const theme = event.target.value; - const editorCreateOptionsString = document.settingsEditor.getValue(); - const replaceTheme = (theme === "dark") ? "vs" : "vs-dark"; - const substituteTheme = (theme === "dark") ? "vs-dark" : "vs"; - const regex = new RegExp('("theme"\\s*:\\s*)"' + replaceTheme + '"', "gm"); - setEditorValue(document.settingsEditor, editorCreateOptionsString.replace(regex, '$1"' + substituteTheme + '"')); - }); - document.settingsEditor = monaco.editor.create(document.getElementById("settings-monaco-editor"), { - language: "json", ...document.appConfig.editorCreateOptions - }); -} - -function openSettings() { - setVisibility(document.getElementById("settings"), true, true); - if (!document.settingsEditor) { - initSettings(); - } - // fill settings form - document.getElementById("theme").value = document.appConfig.theme; - document.getElementById("diagramPreviewType").value = document.appConfig.diagramPreviewType; - document.getElementById("editorWatcherTimeout").value = document.appConfig.editorWatcherTimeout; - setEditorValue(document.settingsEditor, JSON.stringify(document.appConfig.editorCreateOptions, null, " ")); -} - -function saveSettings() { - const appConfig = Object.assign({}, document.appConfig); - appConfig.theme = document.getElementById("theme").value; - appConfig.editorWatcherTimeout = document.getElementById("editorWatcherTimeout").value; - appConfig.diagramPreviewType = document.getElementById("diagramPreviewType").value; - appConfig.editorCreateOptions = JSON.parse(document.settingsEditor.getValue()); - broadcastSettings(appConfig); - closeModal("settings"); -} - -function broadcastSettings(appConfig) { - localStorage.setItem("document.appConfig", JSON.stringify(appConfig)); - sendMessage({ - sender: "settings", - data: { appConfig }, - synchronize: true, - }); -} - -function applySettings() { - setTheme(document.appConfig.theme); - document.editor?.updateOptions(document.appConfig.editorCreateOptions); - document.settingsEditor?.updateOptions(document.appConfig.editorCreateOptions); -} - - -// ========================================================================================================== -// == diagram import == - -function openDiagramImportDialog(isOpenManually = true) { - const diagramImportDialog = document.getElementById("diagram-import"); - setVisibility(diagramImportDialog, true, true); - diagramImportDialog.dataset.isOpenManually = isOpenManually.toString(); -} - -function onDiagramImportInputChange(fileInput) { - document.getElementById("diagram-import-error-message").innerText = ""; - document.getElementById("diagram-import-ok-btn").disabled = fileInput.files?.length < 1; -} - -function initDiagramImportDiaglog() { - const diagramImportDialog = document.getElementById("diagram-import"); - const diagramInputElement = document.getElementById("diagram-import-input"); - const errorMessageElement = document.getElementById("diagram-import-error-message"); - - function closeDiagramImportDialog() { - diagramInputElement.value = ""; // reset or clear - onDiagramImportInputChange(diagramInputElement); - diagramImportDialog.removeAttribute("data-is-open-manually"); - setVisibility(diagramImportDialog, false); - } - function checkFileLocally(file) { - function getImageFileType({name, type}) { - const supported = ["png", "svg"]; - // get type by mime type - let fileType = supported.filter(t => type.toLowerCase().indexOf(t) !== -1)[0]; - if (fileType) return fileType; - // fallback: get type by filename extension - if (name.indexOf(".") === -1) return undefined; - const ext = name.substring(name.lastIndexOf(".")+1).toLowerCase(); - return supported.filter(t => ext === t)[0]; - } - function isDiagramCode({name, type}) { - // get type by mime type - let supported = ["plain", "text", "plantuml", "puml"]; - if (supported.filter(t => type.toLowerCase().indexOf(t) !== -1).length > 0) { - return true; - } - // fallback: get type by filename extension - if (name.indexOf(".") === -1) return false; - const ext = name.substring(name.lastIndexOf('.')+1).toLowerCase(); - supported = ["txt", "puml", "plantuml"]; - return supported.filter(t => ext === t).length > 0; - } - - const type = getImageFileType(file); - const isCode = type === undefined ? isDiagramCode(file) : false; - if (!type && !isCode) { - errorMessageElement.innerText = "File not supported. " + - "Only PNG and SVG diagram images as well as PlantUML code text files are supported." - } - return { type, isDiagramCode: isCode, valid: type || isCode }; - } - - function importDiagram(file, fileCheck) { - function loadDiagram(code) { - syncCodeEditor(code); - broadcastCodeEditorChanges("file-drop", code); - } - - diagramImportDialog.classList.add("wait"); - return new Promise((resolve, reject) => { - if (fileCheck.type) { - // upload diagram image, get meta data from server and load diagram from result - requestMetadata(file).then( - metadata => { loadDiagram(metadata.decoded); resolve(); }, - ({ response }) => { errorMessageElement.innerText = response.message || response; reject(); } - ); - } else if (fileCheck.isDiagramCode) { - // read code (text) file - const reader = new FileReader(); - reader.onload = event => loadDiagram(event.target.result); - reader.readAsText(file); - resolve(); - } else { - // this error should already be handled. - errorMessageElement.innerText = "File not supported. " + - "Only PNG and SVG diagram images as well as PlantUML code text files are supported."; - reject(); - } - }).then(() => closeDiagramImportDialog(), () => {}).finally(() => diagramImportDialog.classList.remove("wait")); - } - - function onGlobalDragEnter(event) { - event.stopPropagation(); - event.preventDefault(); - if (!isVisible(diagramImportDialog)) { - openDiagramImportDialog(false); - } - } - - function onDiagramImportDragOver(event) { - event.stopPropagation(); - event.preventDefault(); - if (event.dataTransfer !== null) { - event.dataTransfer.dropEffect = "copy"; - } - } - function onDiagramImportDrop(event) { - function stop() { - event.stopPropagation(); - event.preventDefault(); - } - const files = event.dataTransfer.files || event.target.files; - if (!files || files.length < 1) { - return stop(); - } - const file = files[0]; - const fileCheck = checkFileLocally(file); - if (!fileCheck.valid) { - return stop(); - } - if (diagramImportDialog.dataset.isOpenManually === "true") { - return; // let file input handle this event => no `stop()`! - } - // drop and go - close modal without additional ok button click - stop(); - importDiagram(file, fileCheck); - } - - // global drag&drop events - window.addEventListener("dragenter", onGlobalDragEnter, false); - // diagram import dialog drag&drop events - diagramImportDialog.addEventListener("dragenter", event => event.target.classList.add("drop-able"), false); - diagramImportDialog.addEventListener("dragover", onDiagramImportDragOver, false); - diagramImportDialog.addEventListener("dragexit", event => event.target.classList.remove("drop-able"), false); - diagramImportDialog.addEventListener("drop", onDiagramImportDrop, false); - // ok button - document.getElementById("diagram-import-ok-btn").addEventListener("click", () => { - const file = diagramInputElement.files[0]; // should be always a valid file - importDiagram(file, checkFileLocally(file)); // otherwise button should be disabled - }); - // reset or clear file input - diagramInputElement.value = ""; - onDiagramImportInputChange(diagramInputElement); - // register model listeners - registerModalListener("diagram-import", openDiagramImportDialog, closeDiagramImportDialog); -} - - -// ========================================================================================================== -// == diagram export == - -function initFileExportDialog() { - const filenameInput = document.getElementById("download-name"); - const fileTypeSelect = document.getElementById("download-type"); - - function openDiagramExportDialog() { - setVisibility(document.getElementById("diagram-export"), true, true); - const code = document.editor.getValue(); - const name = Array.from( - code.matchAll(/^\s*@start[a-zA-Z]*\s+([a-zA-Z-_äöüÄÖÜß ]+)\s*$/gm), - m => m[1] - )[0] || "diagram"; - filenameInput.value = name + ".puml"; - fileTypeSelect.value = "code"; - filenameInput.focus(); - } - function splitFilename(filename) { - const idx = filename.lastIndexOf("."); - if (idx < 1) { - return { name: filename, ext: null }; - } - if (idx === filename.length - 1) { - return { name: filename.slice(0, -1), ext: null }; - } - return { - name: filename.substring(0, idx), - ext: filename.substring(idx + 1), - }; - } - function getExtensionByType(type) { - switch (type) { - case "epstext": return "eps"; - case "code": return "puml"; - default: return type; - } - } - function getTypeByExtension(ext) { - if (!ext) return ext; - ext = ext.toLowerCase(); - switch (ext) { - case "puml": - case "plantuml": - case "code": - return "code"; - case "ascii": return "txt" - default: return ext; - } - } - function onTypeChanged(event) { - const type = event.target.value; - const ext = getExtensionByType(type); - const { name } = splitFilename(filenameInput.value); - filenameInput.value = name + "." + ext; - } - function onFilenameChanged(event) { - const { ext } = splitFilename(event.target.value); - const type = getTypeByExtension(ext); - if (!type) return; - fileTypeSelect.value = type; - } - function downloadFile() { - const filename = filenameInput.value; - const type = fileTypeSelect.value; - const link = document.createElement("a"); - link.download = filename; - if (type === "code") { - const code = document.editor.getValue(); - link.href = "data:," + encodeURIComponent(code); - } else { - if (document.appData.index !== undefined) { - link.href = type + "/" + document.appData.index + "/" + document.appData.encodedDiagram; - } else { - link.href = type + "/" + document.appData.encodedDiagram; - } - } - link.click(); - } - - // register modal - registerModalListener("diagram-export", openDiagramExportDialog); - // add listener - filenameInput.addEventListener("change", onFilenameChanged); - fileTypeSelect.addEventListener("change", onTypeChanged); - document.getElementById("diagram-export-ok-btn").addEventListener("click", downloadFile); - // add Ctrl+S or Meta+S (Mac) key shortcut to open export dialog - window.addEventListener("keydown", event => { - if (event.key === "s" && (isMac ? event.metaKey : event.ctrlKey)) { - event.preventDefault(); - if (!isModalOpen("diagram-export")) { - openDiagramExportDialog(); - } - } - }, false); -} - - -// ========================================================================================================== -// == dock (pop in) and undock (pop out) previewer == - -function getDockUndockElements() { - return { - "btnUndock": document.getElementById("btn-undock"), - "btnDock": document.getElementById("btn-dock"), - "editorContainer": document.getElementById("editor-main-container"), - "previewContainer": document.getElementById("previewer-main-container"), - }; -} - -function hidePreview() { - const elements = getDockUndockElements(); - setVisibility(elements.btnUndock, false); - // if not opened via button and therefore a popup, `window.close` won't work - setVisibility(elements.btnDock, window.opener); - if (elements.editorContainer) elements.editorContainer.style.width = "100%"; - if (elements.previewContainer) setVisibility(elements.previewContainer, false); -} - -function showPreview() { - const elements = getDockUndockElements(); - setVisibility(elements.btnUndock, true); - setVisibility(elements.btnDock, false); - if (elements.editorContainer) elements.editorContainer.style.removeProperty("width"); - if (elements.previewContainer) setVisibility(elements.previewContainer, true); -} - -function undock() { - const url = new URL(window.location.href); - url.searchParams.set("view", "previewer"); - const previewer = window.open(url, "PlantUML Diagram Previewer", "popup"); - if (previewer) { - previewer.onbeforeunload = showPreview; - hidePreview(); - } -} - - -// ========================================================================================================== -// == paginator == - -function getNumberOfDiagramPagesFromCode(code) { - // count `newpage` inside code - // known issue: a `newpage` starting in a newline inside a multiline comment will also be counted - return code.match(/^\s*newpage\s?.*$/gm)?.length + 1 || 1; -} - -function updateNumberOfPagingElements(paginator, pages) { - // remove elements (buttons) if there are to many - while (paginator.childElementCount > pages) { - paginator.removeChild(paginator.lastChild) - } - // add elements (buttons) if there are to less - while (paginator.childElementCount < pages) { - const radioBtn = document.createElement("input"); - radioBtn.name = "paginator"; - radioBtn.type = "radio"; - radioBtn.value = paginator.childElementCount; - radioBtn.addEventListener("click", (event) => { - sendMessage({ - sender: "paginator", - data: { index: event.target.value }, - synchronize: true, - }); - }); - paginator.appendChild(radioBtn); - } -} - -function updatePaginator() { - const paginator = document.getElementById("paginator"); - const pages = document.appData.numberOfDiagramPages; - if (pages > 1) { - updateNumberOfPagingElements(paginator, pages); - setVisibility(paginator, true); - } else { - setVisibility(paginator, false); - } -} - -function updatePaginatorSelection() { - const paginator = document.getElementById("paginator"); - const index = document.appData.index; - if (index === undefined) { - for (const node of paginator.childNodes) { - node.checked = false; - } - } else { - paginator.childNodes[index].checked = true; - } -} - - -// ========================================================================================================== -// == sync data == - -function setEditorValue(editor, text, forceMoveMarkers=undefined) { - // replace editor value but preserve undo stack - editor.executeEdits('', [{ range: editor.getModel().getFullModelRange(), text, forceMoveMarkers }]); -} - -function updateDiagramMap(mapString, mapEl) { - const mapBtn = document.getElementById("map-diagram-link"); - mapEl = mapEl || document.getElementById("plantuml_map"); - if (mapString) { - const div = document.createElement("div"); - div.innerHTML = mapString; - mapEl.parentNode.replaceChild(div.firstChild, mapEl); - setVisibility(mapBtn, true); - } else { - removeChildren(mapEl); - setVisibility(mapBtn, false); - } -} - -function updateSvgDiagram(svgString, svgEl) { - svgEl = svgEl || document.getElementById("diagram-svg"); - const div = document.createElement("div"); - div.innerHTML = svgString; - const newSvg = div.querySelector("svg"); - newSvg.id = "diagram-svg"; - svgEl.parentNode.replaceChild(newSvg, svgEl); -} - -function updateTxtDiagram(txtString, txtEl) { - txtEl = txtEl || document.getElementById("diagram-txt"); - txtEl.innerHTML = txtString; -} - -function syncDiagram(type, encodedDiagram, index) { - const container = document.getElementById("diagram"); - const png = document.getElementById("diagram-png"); - const map = document.getElementById("plantuml_map"); - const svg = document.getElementById("diagram-svg"); - const txt = document.getElementById("diagram-txt"); - const pdf = document.getElementById("diagram-pdf"); - - return new Promise((resolve, reject) => { - if (type === "png") { - png.src = buildUrl(type, encodedDiagram, index); - requestDiagramMap(encodedDiagram, index, (mapString) => { - updateDiagramMap(mapString, map); - resolve(); - }); - } else if (type === "svg") { - requestDiagram(type, encodedDiagram, index, (svgString) => { - updateSvgDiagram(svgString, svg); - resolve(); - }); - } else if (type === "txt") { - requestDiagram(type, encodedDiagram, index, (svgString) => { - updateTxtDiagram(svgString, txt); - resolve(); - }); - } else if (type === "pdf") { - pdf.data = buildUrl(type, encodedDiagram, index); - resolve(); - } else { - (console.error || console.log)("unknown diagram type:", type); - reject(); - } - }).then(() => { - container.setAttribute("data-diagram-type", type); - setVisibility(png, type === "png"); - setVisibility(map, type === "png"); - setVisibility(svg, type === "svg"); - setVisibility(txt, type === "txt"); - setVisibility(pdf, type === "pdf"); - }); -} - -function syncUrlTextInput(encodedDiagram, index) { - const target = document.getElementById("url"); - document.appConfig.changeEventsEnabled = false; - target.value = resolvePath(buildUrl("png", encodedDiagram, index)); - target.title = target.value; - document.appConfig.changeEventsEnabled = true; -} - -function syncCodeEditor(code) { - document.appConfig.changeEventsEnabled = false; - setEditorValue(document.editor, code); - document.appConfig.changeEventsEnabled = true; -} - -function syncBrowserHistory(encodedDiagram, index) { - const url = replaceUrl(window.location.href, encodedDiagram, index).url; - history.replaceState(history.stat, document.title, url); -} - -function syncStaticPageData(includePaginatorUpdates) { - document.appConfig.autoRefreshState = "syncing"; - const encodedDiagram = document.appData.encodedDiagram; - const index = document.appData.index; - return Promise.all([ - // update URL input - new Promise((resolve, _reject) => { - if (document.getElementById("url")) { - syncUrlTextInput(encodedDiagram, index); - } - resolve(); - }), - // update diagram image - syncDiagram(document.appConfig.diagramPreviewType, encodedDiagram, index), - // update external diagram links - new Promise((resolve, _reject) => { - for (let target of document.getElementsByClassName("diagram-link")) { - target.href = buildUrl(target.dataset.imgType, encodedDiagram, index); - } - resolve(); - }), - // update paginator - new Promise((resolve, _reject) => { - if (includePaginatorUpdates) { - updatePaginator(); - updatePaginatorSelection(); - } - resolve(); - }), - // update browser url as well as the browser history - new Promise((resolve, _reject) => { - syncBrowserHistory(encodedDiagram, index); - resolve(); - }), - ]).then(() => { - // set auto refresh state to complete - document.appConfig.autoRefreshState = "complete"; - }); -} - - -// ========================================================================================================== -// == initialize app == - -async function initializeApp(view) { - await loadCodeEditor(); - if (view !== "previewer") { - initializeCodeEditor(); - initializeUrlInput(); - } - initializeAppData(); - initTheme(); - await initializeDiagram(); - initializePaginator(); - initModals(); - if (view !== "previewer") { - initDiagramImportDiaglog(); - initFileExportDialog(); - addSavePlantumlDocumentEvent(); - } - if (["previewer", "editor"].includes(view)) { - hidePreview(); - } - document.appConfig.autoRefreshState = "complete"; - document.editor?.focus(); -} - -function loadCodeEditor() { - // load Monaco editor asynchron - return new Promise((resolve, _reject) => { - require.config({ paths: { vs: "webjars/monaco-editor/0.36.1/min/vs" } }); - require(["vs/editor/editor.main"], resolve); - }); -} - -const broadcastCodeEditorChanges = (function() { - let plantumlFeatures; - return function(sender, code) { - plantumlFeatures = plantumlFeatures || new PlantUmlLanguageFeatures(); - document.appConfig.autoRefreshState = "started"; - const numberOfDiagramPages = getNumberOfDiagramPagesFromCode(code); - let index = document.appData.index; - if (index === undefined || numberOfDiagramPages === 1) { - index = undefined; - } else if (index >= numberOfDiagramPages) { - index = numberOfDiagramPages - 1; - } - encodeDiagram(code, (encodedDiagram) => { - sendMessage({ - sender, - data: { encodedDiagram, numberOfDiagramPages, index }, - synchronize: true, - }); - }); - const model = document.editor.getModel(); - plantumlFeatures.validateCode(model) - .then(markers => monaco.editor.setModelMarkers(model, "plantuml", markers)); - }; -})(); - -function initializeCodeEditor() { - // create editor model including editor watcher - let timer = 0; - const uri = monaco.Uri.parse("inmemory://plantuml"); - const initCodeEl = document.getElementById("initCode"); - const initCode = initCodeEl.value; - initCodeEl.remove(); - const model = monaco.editor.createModel(initCode, "apex", uri); - model.onDidChangeContent(() => { - clearTimeout(timer); - if (document.appConfig.changeEventsEnabled) { - document.appConfig.autoRefreshState = "waiting"; - timer = setTimeout( - () => broadcastCodeEditorChanges("editor", model.getValue()), - document.appConfig.editorWatcherTimeout - ); - } - }); - // create storage service to expand suggestion documentation by default - const storageService = { - get() {}, - getBoolean(key) { return key === 'expandSuggestionDocs'; }, - getNumber() { return 0; }, - remove() {}, - store() {}, - onWillSaveState() {}, - onDidChangeStorage() {}, - onDidChangeValue() {}, - }; - // create editor - document.editor = monaco.editor.create(document.getElementById("monaco-editor"), { - model, ...document.appConfig.editorCreateOptions - }, { storageService }); - // sometimes the monaco editor has resize problems - document.addEventListener("resize", () => document.editor.layout()); -} - -function initializeUrlInput() { - // resolve relative path inside url input once - const urlInput = document.getElementById("url"); - urlInput.value = resolvePath(urlInput.value); - urlInput.title = urlInput.value; - - // update editor and everything else if the URL input is changed - urlInput.addEventListener("change", (event) => { - if (document.appConfig.changeEventsEnabled) { - document.appConfig.autoRefreshState = "started"; - const analysedUrl = analyseUrl(event.target.value); - decodeDiagram(analysedUrl.encodedDiagram, (code) => { - syncCodeEditor(code); - sendMessage({ - sender: "url", - data: { - encodedDiagram: analysedUrl.encodedDiagram, - index: analysedUrl.index, - }, - synchronize: true, - }); - }); - } - }); -} - -function initializeAppData() { - const analysedUrl = analyseUrl(window.location.href); - const code = document.editor?.getValue(); - document.appData = Object.assign({}, window.opener?.document.appData); - if (Object.keys(document.appData).length === 0) { - document.appData = { - encodedDiagram: analysedUrl.encodedDiagram, - index: analysedUrl.index, - numberOfDiagramPages: (code) ? getNumberOfDiagramPagesFromCode(code) : 1, - }; - } -} - -function initTheme() { - function changeEditorThemeSettingIfNecessary(theme) { - if (theme === "dark" && document.appConfig.editorCreateOptions.theme === "vs") { - document.appConfig.editorCreateOptions.theme = "vs-dark"; - } - if (theme === "light" && document.appConfig.editorCreateOptions.theme === "vs-dark") { - document.appConfig.editorCreateOptions.theme = "vs"; - } - } - // set theme to last saved settings or browser preference or "light" - document.appConfig.theme = document.appConfig.theme || getBrowserThemePreferences() || "light"; - setTheme(document.appConfig.theme); - changeEditorThemeSettingIfNecessary(document.appConfig.theme); - // listen to browser change event - window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", event => { - const theme = event.matches ? "dark" : "light"; - document.appConfig.theme = theme - changeEditorThemeSettingIfNecessary(theme); - broadcastSettings(document.appConfig); - }); -} - -function initializeDiagram() { - if (document.appConfig.diagramPreviewType === "png") { - return Promise.resolve(); // png is initialized by default - } - return syncDiagram( - document.appConfig.diagramPreviewType, - document.appData.encodedDiagram, - document.appData.index - ); -} - -function initializePaginator() { - if (document.appData.numberOfDiagramPages > 1) { - updatePaginator(); - updatePaginatorSelection(); - } -} - -function addSavePlantumlDocumentEvent() { - window.addEventListener("keydown", function(e) { - if (e.key === "," && (isMac ? e.metaKey : e.ctrlKey)) { - // support Ctrl+, to open the settings - e.preventDefault(); - if (!isModalOpen("settings")) { - openSettings(); - } - } - }, false); -} - - -// ========================================================================================================== -// == communication == -// -// send and receive data: { -// sender: string = ["editor"|"url"|"paginator"|"settings"|"file-drop"], -// data: { -// encodedDiagram: string | undefined, -// index: integer | undefined, -// numberOfDiagramPages: integer | undefined, -// appConfig: object | undefined -// } | undefined, -// synchronize: boolean = false, -// reload: boolean = false, // reload page -// force: boolean = false // force synchronize or reload -// } - -function sendMessage(data) { - (new BroadcastChannel("plantuml-server")).postMessage(data, window.location.origin); -} - -function updateReceiveMessageData(data) { - if (!data || Object.keys(data).length === 0) return {}; - - const changedFlags = {}; - if ("encodedDiagram" in data && data.encodedDiagram !== document.appData.encodedDiagram) { - document.appData.encodedDiagram = data.encodedDiagram; - changedFlags.diagram = true; - } - if ("index" in data && data.index !== document.appData.index) { - document.appData.index = data.index; - changedFlags.index = true; - } - if ("numberOfDiagramPages" in data && data.numberOfDiagramPages !== document.appData.numberOfDiagramPages) { - document.appData.numberOfDiagramPages = data.numberOfDiagramPages; - changedFlags.numberOfDiagramPages = true; - } - if ("appConfig" in data && data.appConfig !== document.appConfig) { - document.appConfig = data.appConfig; - changedFlags.appConfig = true; - } - return changedFlags; -} - -async function receiveMessage(event) { - const data = event.data.data; - const force = event.data.force || false; - const changedFlags = updateReceiveMessageData(data); - if (event.data.synchronize === true) { - if (force || changedFlags.diagram || changedFlags.index || changedFlags.appConfig) { - await syncStaticPageData(false); - } - if (force || changedFlags.numberOfDiagramPages) { - updatePaginator(); - } - if (force || changedFlags.numberOfDiagramPages || changedFlags.index) { - updatePaginatorSelection(); - } - if (changedFlags.appConfig) { - applySettings(); - } - } - if (event.data.reload === true) { - window.location.reload(); - } -} - - -// ========================================================================================================== -// == main entry == - -window.onload = function() { - const view = new URL(window.location.href).searchParams.get("view")?.toLowerCase(); - initializeApp(view); - - // broadcast channel - const bc = new BroadcastChannel("plantuml-server"); - bc.onmessage = receiveMessage; -}; diff --git a/src/main/webapp/plantumllanguage.js b/src/main/webapp/plantumllanguage.js deleted file mode 100644 index f40cf2f..0000000 --- a/src/main/webapp/plantumllanguage.js +++ /dev/null @@ -1,437 +0,0 @@ -/******************************************* -* Monaco Editor PlantUML language features * -********************************************/ - -/** - * Monaco Editor PlantUML Language Features. - * - * @example - * ```js - * plantumlFeatures = new PlantUmlLanguageFeatures(); - * const model = monaco.editor.createModel(initCode, "apex", uri); - * model.onDidChangeContent(() => plantumlFeatures.validateCode(model)); - * ``` - */ -const PlantUmlLanguageFeatures = (function() { - 'use strict'; - - /** - * Create Monaco Editor PlantUML Language Features instance. - * - * @param {object} [options] global instance options - */ - function PlantUmlLanguageFeatures({ - baseUrl = "", - languageSelector = ["apex", "plantuml"], - initialize = true - } = {}) { - - const validationEventListeners = {}; - - - // ========================================================================================================== - // == PlantUML valdation methods == - - /** - * Add validation event listener. - * - * Validation Event Order: - * before -> code -> line -> after - * - * @param {("before"|"code"|"line"|"after")} type before|code|line|after event type - * @param {(event: any) => Promise|editor.IMarkerData|Promise|editor.IMarkerData[]|Promise|void} listener event listener - */ - this.addValidationEventListener = (type, listener) => { - if (!["before", "code", "line", "after"].includes(type)) { - throw Error("Unknown validation event type: " + type); - } - validationEventListeners[type] = validationEventListeners[type] || []; - validationEventListeners[type].push(listener); - }; - - /** - * Validate PlantUML language of monaco editor model. - * - * @param {editor.ITextModel} model editor model to validate - * - * @returns editor markers as promise - * - * @example - * ```js - * validateCode(editor.getModel()) - * .then(markers => monaco.editor.setModelMarkers(model, "plantuml", markers)); - * ``` - */ - this.validateCode = async (model) => { - const promises = []; - - // raise before events - promises.push(validationEventListeners.before?.map(listener => listener({ model }))); - - // raise code events - promises.push(validationEventListeners.code?.map(listener => listener({ model, code: model.getValue() }))); - - if (validationEventListeners.line && validationEventListeners.line.length > 0) { - // NOTE: lines and columns start at 1 - const lineCount = model.getLineCount(); - for (let lineNumber = 1; lineNumber <= lineCount; lineNumber++) { - const range = { - startLineNumber: lineNumber, - startColumn: 1, - endLineNumber: lineNumber, - endColumn: model.getLineLength(lineNumber) + 1, - }; - const line = model.getValueInRange(range); - // raise line events - promises.push(validationEventListeners.line?.map(listener => listener({ model, range, line, lineNumber, lineCount }))); - } - } - - // raise after events - promises.push(validationEventListeners.after?.map(listener => listener({ model }))); - - // collect all markers and ... - // - since each event can results in an array of markers -> `flat(1)` - // - since not each event has to results in markers and can be `undef - return Promise.all(promises).then(results => results.flat(1).filter(marker => marker)); - }; - - /** - * Add PlantUML `@start` and `@end` command validation. - */ - this.addStartEndValidationListeners = () => { - let diagramType = undefined; - let startCounter = 0; - let endCounter = 0; - - // reset validation cache - this.addValidationEventListener("before", () => { - diagramType = undefined; - startCounter = 0; - endCounter = 0; - }); - - // @start should be the first command - this.addValidationEventListener("code", ({ model, code }) => { - const match = code.match(/^(('.*)|\s)*@start(?\w+)/); - if (match) { - diagramType = match.groups.type; - return; // diagram code starts with a `@start` - } - return { - message: "PlantUML diagrams should begin with the `@start` command and `@start` should also be the first command.", - severity: monaco.MarkerSeverity.Warning, - startLineNumber: 1, - startColumn: 1, - endLineNumber: 1, - endColumn: model.getLineLength(1) + 1, - }; - }); - - // @end should be the last command and should be of the same type (e.g. @startjson ... @endjson) - this.addValidationEventListener("code", ({ model, code }) => { - const lineCount = model.getLineCount(); - const match = code.match(/\s+@end(?\w+)(('.*)|\s)*$/); - if (match) { - if (diagramType === match.groups.type) { - return; // diagram code ends with a `@end` of the same type as the `@start` - } - return { - message: "PlantUML diagrams should start and end with the type.\nExample: `@startjson ... @endjson`", - severity: monaco.MarkerSeverity.Error, - startLineNumber: lineCount, - startColumn: 1, - endLineNumber: lineCount, - endColumn: model.getLineLength(lineCount) + 1, - }; - } - return { - message: "PlantUML diagrams should end with the `@end` command and `@end` should also be the last command.", - severity: monaco.MarkerSeverity.Warning, - startLineNumber: lineCount, - startColumn: 1, - endLineNumber: lineCount, - endColumn: model.getLineLength(lineCount) + 1, - }; - }); - - // @start should only be used once - this.addValidationEventListener("line", ({ range, line }) => { - const match = line.match(/^\s*@start(?\w+)(\s+.*)?$/); - if (!match) return; - - startCounter += 1; - if (startCounter > 1) { - const word = "@start" + match.groups.type; - const wordIndex = line.indexOf(word); - return { - message: "Multiple @start commands detected.", - severity: monaco.MarkerSeverity.Warning, - startLineNumber: range.startLineNumber, - startColumn: wordIndex + 1, - endLineNumber: range.endLineNumber, - endColumn: wordIndex + word.length + 1, - }; - } - }); - - // @end should only be used once - this.addValidationEventListener("line", ({ range, line }) => { - const match = line.match(/^\s*@end(?\w+)(\s+.*)?$/); - if (!match) return; - - endCounter += 1; - if (endCounter > 1) { - const word = "@end" + match.groups.type; - const wordIndex = line.indexOf(word); - return { - message: "Multiple @end commands detected.", - severity: monaco.MarkerSeverity.Warning, - startLineNumber: range.startLineNumber, - startColumn: wordIndex + 1, - endLineNumber: range.endLineNumber, - endColumn: wordIndex + word.length + 1, - }; - } - }); - }; - - - // ========================================================================================================== - // == PlantUML code completion methods == - - this.registerThemeCompletion = () => { - const createThemeProposals = async (range, filter = undefined) => { - const themes = await this.getThemes(); - return themes?.filter(theme => filter ? theme.includes(filter) : true) - .map(theme => ({ - label: theme, - kind: monaco.languages.CompletionItemKind.Text, - documentation: "PlantUML " + theme + " theme", - insertText: theme, - range: range, - })) || []; - }; - - monaco.languages.registerCompletionItemProvider(languageSelector, { - triggerCharacters: [" "], - provideCompletionItems: async (model, position) => { - const textUntilPosition = model.getValueInRange({ - startLineNumber: position.lineNumber, - startColumn: 1, - endLineNumber: position.lineNumber, - endColumn: position.column, - }); - if (textUntilPosition.match(/^\s*!(t(h(e(m(e)?)?)?)?)?$/)) { - return { - suggestions: [ - { - label: 'theme', - kind: monaco.languages.CompletionItemKind.Keyword, - documentation: "PlantUML theme command", - insertText: 'theme', - range: getWordRange(model, position), - } - ] - }; - } - const match = textUntilPosition.match(/^\s*!theme\s+(?[^\s]*)$/); - if (match) { - const suggestions = await createThemeProposals(getWordRange(model, position), match.groups.theme); - return { suggestions }; - } - return { suggestions: [] }; - } - }); - }; - - this.registerIconCompletion = () => { - const createIconProposals = async (range, filter = undefined) => { - const icons = await this.getIcons(); - return icons?.filter(icon => filter ? icon.includes(filter) : true) - .map(icon => { - // NOTE: markdown image path inside suggestions seems to have rendering issues while using relative paths - const iconUrl = this.resolvePath(baseUrl + "ui-helper?request=icons.svg#" + icon); - return { - label: icon, - kind: monaco.languages.CompletionItemKind.Constant, - documentation: { - //supportHtml: true, // also a possibility but quite limited html - value: "![icon](" + iconUrl + ")   " + icon - }, - insertText: icon + ">", - range: range - }; - }) || []; - }; - - monaco.languages.registerCompletionItemProvider(languageSelector, { - triggerCharacters: ["&"], - provideCompletionItems: async (model, position) => { - const textUntilPosition = model.getValueInRange({ - startLineNumber: position.lineNumber, - startColumn: 1, - endLineNumber: position.lineNumber, - endColumn: position.column, - }); - const match = textUntilPosition.match(/<&(?[^\s>]*)$/); - if (match) { - const suggestions = await createIconProposals(getWordRange(model, position), match.groups.icon); - return { suggestions }; - } - return { suggestions: [] }; - } - }); - }; - - this.registerEmojiCompletion = () => { - const createEmojiProposals = async (range, filter = undefined) => { - const emojis = await this.getEmojis(); - return emojis?.filter(([unicode, name]) => filter ? unicode.includes(filter) || name?.includes(filter) : true) - .map(([unicode, name]) => { - // NOTE: load images direct from GitHub source: https://github.com/twitter/twemoji#download - const emojiUrl = "https://raw.githubusercontent.com/twitter/twemoji/gh-pages/v/13.1.0/svg/" + unicode + ".svg"; - const docHint = (name) ? name + " (" + unicode + ")" : unicode; - const isUnicode = !name || (filter && unicode.includes(filter)); - const label = isUnicode ? unicode : name; - return { - label: label, - kind: monaco.languages.CompletionItemKind.Constant, - documentation: { - //supportHtml: true, // also a possibility but quite limited html - value: "![emoji](" + emojiUrl + ")   " + docHint - }, - insertText: label + ":>", - range: range - }; - }) || []; - }; - - monaco.languages.registerCompletionItemProvider(languageSelector, { - triggerCharacters: [":"], - provideCompletionItems: async (model, position) => { - const textUntilPosition = model.getValueInRange({ - startLineNumber: position.lineNumber, - startColumn: 1, - endLineNumber: position.lineNumber, - endColumn: position.column, - }); - const match = textUntilPosition.match(/<:(?[^\s>]*)$/); - if (match) { - const suggestions = await createEmojiProposals(getWordRange(model, position), match.groups.emoji); - return { suggestions }; - } - return { suggestions: [] }; - } - }); - }; - - - // ========================================================================================================== - // == helper methods == - - this.resolvePath = (path) => { - if (path.startsWith("http")) return path; - if (path.startsWith("/")) return window.location.origin + path; - - if (path.slice(0, 2) == "./") path = path.slice(2); - let base = (document.querySelector("base") || {}).href || window.location.origin; - if (base.slice(-1) == "/") base = base.slice(0, -1); - return base + "/" + path; - }; - - this.getIcons = (function(){ - let icons = undefined; - return async () => { - if (icons === undefined) { - icons = await makeRequest("GET", "ui-helper?request=icons", { responseType: "json" }); - } - return icons; - } - })(); - - this.getEmojis = (function(){ - let emojis = undefined; - return async () => { - if (emojis === undefined) { - emojis = await makeRequest("GET", "ui-helper?request=emojis", { responseType: "json" }); - } - return emojis; - } - })(); - - this.getThemes = (function(){ - let themes = undefined; - return async () => { - if (themes === undefined) { - themes = await makeRequest("GET", "ui-helper?request=themes", { responseType: "json" }); - } - return themes; - } - })(); - - const makeRequest = ( - method, - url, - { data = null, headers = { "Content-Type": "text/plain" }, responseType = "text", ignoreBaseUrl = false } = {} - ) => { - const targetUrl = (ignoreBaseUrl === true) ? url : baseUrl + url; - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.onreadystatechange = function() { - if (xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status >= 200 && xhr.status <= 300) { - if (responseType === "json") { - resolve(xhr.response); - } else { - resolve(xhr.responseText); - } - } else { - if (responseType === "json") { - reject({ status: xhr.status, response: xhr.response }); - } else { - reject({ status: xhr.status, responseText: xhr.responseText }); - } - } - } - } - xhr.open(method, targetUrl, true); - xhr.responseType = responseType; - headers && Object.keys(headers).forEach(key => xhr.setRequestHeader(key, headers[key])); - xhr.send(data); - }); - }; - - const getWordRange = (model, position) => { - const word = model.getWordUntilPosition(position); - return { - startLineNumber: position.lineNumber, - endLineNumber: position.lineNumber, - startColumn: word.startColumn, - endColumn: word.endColumn, - }; - } - - - // ========================================================================================================== - // == constructor running code == - - // prepare base URL - if (baseUrl === null || baseUrl === undefined) { - baseUrl = ""; - } else if (baseUrl !== "") { - // add tailing "/" - if (baseUrl.slice(-1) !== "/") baseUrl = baseUrl + "/"; - } - - // initialize default validation and code completion - if (initialize) { - this.addStartEndValidationListeners(); - this.registerThemeCompletion(); - this.registerIconCompletion(); - this.registerEmojiCompletion(); - } - } - - return PlantUmlLanguageFeatures; -})(); diff --git a/src/main/webapp/previewer.jsp b/src/main/webapp/previewer.jsp index d2462fe..f0e1584 100644 --- a/src/main/webapp/previewer.jsp +++ b/src/main/webapp/previewer.jsp @@ -14,13 +14,19 @@ - <%@ include file="resource/htmlheadbase.jsp" %> + <%@ include file="/components/app-head.jsp" %> PlantUML Server +
<%-- Preview --%> - <%@ include file="resource/preview.jsp" %> + <%@ include file="/components/preview/preview.jsp" %>
diff --git a/src/main/webapp/resource/githubribbon.html b/src/main/webapp/resource/githubribbon.html deleted file mode 100644 index 4c88f3b..0000000 --- a/src/main/webapp/resource/githubribbon.html +++ /dev/null @@ -1,17 +0,0 @@ -
- Fork me on GitHub - - Fork me on GitHub - -
\ No newline at end of file diff --git a/src/main/webapp/resource/preview.jsp b/src/main/webapp/resource/preview.jsp deleted file mode 100644 index 648a779..0000000 --- a/src/main/webapp/resource/preview.jsp +++ /dev/null @@ -1,89 +0,0 @@ -
- -
- -
-
-
- - PlantUML diagram - <% if (hasMap) { %> - <%= map %> - <% } else { %> - - <% } %> - - - - - - -
-
-
- <% if (showSocialButtons) { %> -
- <%@ include file="socialbuttons2.jsp" %> -
- <% } %> - <%@ include file="settings.jsp" %> -
diff --git a/src/main/webapp/resource/socialbuttons1.html b/src/main/webapp/resource/socialbuttons1.html deleted file mode 100644 index b025edc..0000000 --- a/src/main/webapp/resource/socialbuttons1.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file