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 10c08f4..0000000 Binary files a/src/main/webapp/assets/github-fork-me.png and /dev/null differ 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