diff --git a/.editorconfig b/.editorconfig index e22b8fb..825dc27 100644 --- a/.editorconfig +++ b/.editorconfig @@ -20,3 +20,6 @@ insert_final_newline = false [{Dockerfile,Dockerfile.*}] indent_size = 4 + +[.vscode/*.json] +indent_size = 4 diff --git a/.vscode/settings.json b/.vscode/settings.json index 691fda1..7242021 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,6 +12,7 @@ "Lalloni", "monaco", "plantuml", + "puml", "Roques", "servlet", "servlets", @@ -20,5 +21,6 @@ "undock", "utxt" ], - "cSpell.allowCompoundWords": true -} \ No newline at end of file + "cSpell.allowCompoundWords": true, + "svg.preview.background": "transparent" +} diff --git a/docs/WebUI/gifs/diagram-export.gif b/docs/WebUI/gifs/diagram-export.gif new file mode 100644 index 0000000..5c79c01 Binary files /dev/null and b/docs/WebUI/gifs/diagram-export.gif differ diff --git a/docs/WebUI/gifs/diagram-import.gif b/docs/WebUI/gifs/diagram-import.gif new file mode 100644 index 0000000..1749cb5 Binary files /dev/null and b/docs/WebUI/gifs/diagram-import.gif differ diff --git a/docs/WebUI/import-export.md b/docs/WebUI/import-export.md new file mode 100644 index 0000000..a5d3fb0 --- /dev/null +++ b/docs/WebUI/import-export.md @@ -0,0 +1,21 @@ +# Import and Export editable PlantUML Diagrams + +Similar to [draw.io](https://app.diagrams.net) it is possible to load and continue editing PlantUML diagram images. + + +# Export a diagram + +Via the editor menu or Ctrl+S (or Meta+S in the case of a Mac) you can open the file save dialog. +Here you can edit the file name, choose a file/diagram type and download the diagram. +The default is to download the PlantUML code. + +![export](https://raw.githubusercontent.com/plantuml/plantuml-server/master/docs/WebUI/gifs/diagram-export.gif) + + +# Import a diagram + +This feature is based on the PlantUML meta data which currently **support only PNG and SVG** diagrams. +Besides a diagram image, you can of course also load a diagram code file. +Moreover, because it is so nice and convenient, we also added a drag-and-drop feature. + +![import](https://raw.githubusercontent.com/plantuml/plantuml-server/master/docs/WebUI/gifs/diagram-import.gif) diff --git a/src/main/webapp/assets/copy.svg b/src/main/webapp/assets/actions/copy.svg similarity index 100% rename from src/main/webapp/assets/copy.svg rename to src/main/webapp/assets/actions/copy.svg diff --git a/src/main/webapp/assets/dock.svg b/src/main/webapp/assets/actions/dock.svg similarity index 100% rename from src/main/webapp/assets/dock.svg rename to src/main/webapp/assets/actions/dock.svg diff --git a/src/main/webapp/assets/actions/download.svg b/src/main/webapp/assets/actions/download.svg new file mode 100644 index 0000000..8729bb7 --- /dev/null +++ b/src/main/webapp/assets/actions/download.svg @@ -0,0 +1 @@ + diff --git a/src/main/webapp/assets/settings.svg b/src/main/webapp/assets/actions/settings.svg similarity index 100% rename from src/main/webapp/assets/settings.svg rename to src/main/webapp/assets/actions/settings.svg diff --git a/src/main/webapp/assets/undock.svg b/src/main/webapp/assets/actions/undock.svg similarity index 100% rename from src/main/webapp/assets/undock.svg rename to src/main/webapp/assets/actions/undock.svg diff --git a/src/main/webapp/assets/actions/upload.svg b/src/main/webapp/assets/actions/upload.svg new file mode 100644 index 0000000..50c7222 --- /dev/null +++ b/src/main/webapp/assets/actions/upload.svg @@ -0,0 +1 @@ + diff --git a/src/main/webapp/index.jsp b/src/main/webapp/index.jsp index 265c90a..7732650 100644 --- a/src/main/webapp/index.jsp +++ b/src/main/webapp/index.jsp @@ -36,13 +36,26 @@
- +
- +
+ + +
@@ -52,6 +65,9 @@ + + <%@ include file="resource/diagram-import.jsp" %> + <%@ include file="resource/diagram-export.jsp" %>
diff --git a/src/main/webapp/plantuml.css b/src/main/webapp/plantuml.css index 196f2ac..adcef98 100644 --- a/src/main/webapp/plantuml.css +++ b/src/main/webapp/plantuml.css @@ -6,19 +6,25 @@ :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; - --settings-bg-color: #fefefe; + --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; - --settings-bg-color: #424242; + --modal-bg-color: #424242; + --file-drop-color: #212121; } /************* default settings *************/ @@ -43,7 +49,11 @@ body { height: 100%; } } -input:not([type=image]) { +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); } @@ -72,6 +82,14 @@ select { min-width: 3px; } +/************* wait cursor *************/ +.wait { + cursor: wait; +} +.wait > * { + pointer-events: none; +} + /************* flex rows and columns *************/ .flex-columns { display: flex; @@ -181,24 +199,102 @@ select { box-shadow: none; outline: none; } -.btn-input input[type=image] { +.btn-input input[type="image"] { height: 1rem; margin-left: 0.7em; padding: 0 0.3em; } -/************* Monaco editor copy button *************/ -.monaco-editor-container input[type=image] { - height: 1.5rem; +/************* Monaco editor action menu *************/ +.monaco-editor-container .editor-menu { position: absolute; - right: 2rem; - top: 1rem; - opacity: 0.5; + right: 0; + top: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + flex: 1; } -.monaco-editor-container input[type=image]:hover { +.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 { @@ -300,7 +396,6 @@ select { } /*******************************************************************/ -/************* settings *************/ /************* modal *************/ .modal { display: block; @@ -315,8 +410,8 @@ select { background-color: rgb(0, 0, 0); background-color: rgba(0, 0, 0, 0.4); } -.modal-content { - background-color: var(--settings-bg-color); +.modal .modal-content { + background-color: var(--modal-bg-color); margin: auto; padding: 2rem; border: 3px solid var(--border-color); @@ -339,52 +434,96 @@ select { to { top: 50%; opacity: 1; } } /************* header, main, footer *************/ -#settings .settings-header h2 { +.modal .modal-header h2 { margin: 0; } -#settings .settings-main { +.modal .modal-main { flex: 1; } -#settings .settings-footer { +.modal .modal-footer { + margin-top: 1rem; text-align: right; } -/************* label + input *************/ -#settings .setting { +/************* 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; } -#settings .setting:first-child { - margin: 0; +.modal .label-input-pair:first-child { + margin-top: 0; } -#settings .setting label { +.modal .label-input-pair:last-child { + margin-bottom: 0; +} +.modal .label-input-pair label { display: inline-block; min-width: 15rem; } -#settings .setting label + input, #settings .setting label + select { +.modal .label-input-pair label + input, +.modal .label-input-pair label + select { box-sizing: border-box; display: inline-block; min-width: 10rem; } -#settings input, #settings select { - border: 1px solid var(--border-color); -} -#settings input:not(:focus):invalid { - border-bottom-color: red; -} -/************* settings editor *************/ + +/************* settings *************/ #settings #settings-monaco-editor { height: 17rem; border: 1px solid var(--border-color); } -/************* ok + cancel buttons *************/ -#settings input.ok, #settings input.cancel { - min-width: 5rem; +/************* diagram import *************/ +#diagram-import p.error-message { + color: darkred; + padding-left: 1rem; + padding-right: 1rem; } -#settings input.ok:hover { - border-bottom-color: green; +#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; } -#settings input.cancel:hover { - border-bottom-color: darkred; +#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; } /*******************************************************************/ @@ -392,7 +531,7 @@ select { [data-theme="dark"] img:not(#diagram-png):not(.no-filter) { filter: invert() contrast(30%); } -[data-theme="dark"] input[type=image] { +[data-theme="dark"] input[type="image"] { filter: invert() contrast(30%); } [data-theme="dark"] a { diff --git a/src/main/webapp/plantuml.js b/src/main/webapp/plantuml.js index 1b79631..d0fca75 100644 --- a/src/main/webapp/plantuml.js +++ b/src/main/webapp/plantuml.js @@ -43,7 +43,7 @@ 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) + return (el.offsetParent !== null); } function setVisibility(el, visibility, focus=false) { @@ -55,6 +55,11 @@ function setVisibility(el, visibility, focus=false) { } } +const isMac = (function() { + const PLATFORM = navigator?.userAgentData?.platform || navigator?.platform || "unknown"; + return PLATFORM.match("Mac"); +})(); + // ========================================================================================================== // == URL helpers == @@ -184,27 +189,81 @@ 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("settings").addEventListener("keydown", (event) => { - event.preventDefault(); - console.log(event); - if (event.key === "Escape" || event.key === "Esc") { - closeSettings(); - } - }, false); 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"); - document.settingsEditor.getModel().setValue( - editorCreateOptionsString.replace(regex, '$1"' + substituteTheme + '"') - ); + setEditorValue(document.settingsEditor, editorCreateOptionsString.replace(regex, '$1"' + substituteTheme + '"')); }); document.settingsEditor = monaco.editor.create(document.getElementById("settings-monaco-editor"), { language: "json", ...document.appConfig.editorCreateOptions @@ -220,13 +279,7 @@ function openSettings() { document.getElementById("theme").value = document.appConfig.theme; document.getElementById("diagramPreviewType").value = document.appConfig.diagramPreviewType; document.getElementById("editorWatcherTimeout").value = document.appConfig.editorWatcherTimeout; - document.settingsEditor.getModel().setValue( - JSON.stringify(document.appConfig.editorCreateOptions, null, " ") - ); -} - -function closeSettings() { - setVisibility(document.getElementById("settings"), false); + setEditorValue(document.settingsEditor, JSON.stringify(document.appConfig.editorCreateOptions, null, " ")); } function saveSettings() { @@ -236,7 +289,7 @@ function saveSettings() { appConfig.diagramPreviewType = document.getElementById("diagramPreviewType").value; appConfig.editorCreateOptions = JSON.parse(document.settingsEditor.getValue()); broadcastSettings(appConfig); - closeSettings(); + closeModal("settings"); } function broadcastSettings(appConfig) { @@ -255,6 +308,248 @@ function applySettings() { } +// ========================================================================================================== +// == 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 == @@ -353,6 +648,11 @@ function updatePaginatorSelection() { // ========================================================================================================== // == 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"); @@ -433,7 +733,7 @@ function syncUrlTextInput(encodedDiagram, index) { function syncCodeEditor(code) { document.appConfig.changeEventsEnabled = false; - document.editor.getModel().setValue(code); + setEditorValue(document.editor, code); document.appConfig.changeEventsEnabled = true; } @@ -496,7 +796,10 @@ async function initializeApp(view) { initTheme(); await initializeDiagram(); initializePaginator(); + initModals(); if (view !== "previewer") { + initDiagramImportDiaglog(); + initFileExportDialog(); addSavePlantumlDocumentEvent(); } if (["previewer", "editor"].includes(view)) { @@ -514,6 +817,31 @@ function loadCodeEditor() { }); } +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; @@ -521,32 +849,15 @@ function initializeCodeEditor() { const initCodeEl = document.getElementById("initCode"); const initCode = initCodeEl.value; initCodeEl.remove(); - const plantumlFeatures = new PlantUmlLanguageFeatures(); const model = monaco.editor.createModel(initCode, "apex", uri); model.onDidChangeContent(() => { clearTimeout(timer); if (document.appConfig.changeEventsEnabled) { document.appConfig.autoRefreshState = "waiting"; - timer = setTimeout(() => { - document.appConfig.autoRefreshState = "started"; - const code = model.getValue(); - 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: "editor", - data: { encodedDiagram, numberOfDiagramPages, index }, - synchronize: true, - }); - }); - plantumlFeatures.validateCode(model) - .then(markers => monaco.editor.setModelMarkers(model, "plantuml", markers)); - }, document.appConfig.editorWatcherTimeout); + timer = setTimeout( + () => broadcastCodeEditorChanges("editor", model.getValue()), + document.appConfig.editorWatcherTimeout + ); } }); // create storage service to expand suggestion documentation by default @@ -648,26 +959,11 @@ function initializePaginator() { } function addSavePlantumlDocumentEvent() { - const PLATFORM = navigator?.userAgentData?.platform || navigator?.platform || "unknown"; - document.addEventListener("keydown", function(e) { - if (e.key === "s" && (PLATFORM.match("Mac") ? e.metaKey : e.ctrlKey)) { - // support Ctrl+S to download diagram - e.preventDefault(); - 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"; - // download via link - const link = document.createElement("a"); - link.download = name + ".puml"; - link.href = "data:," + encodeURIComponent(code); - link.click(); - } - if (e.key === "," && (PLATFORM.match("Mac") ? e.metaKey : e.ctrlKey)) { + window.addEventListener("keydown", function(e) { + if (e.key === "," && (isMac ? e.metaKey : e.ctrlKey)) { // support Ctrl+, to open the settings e.preventDefault(); - if (document.getElementById("settings")?.style?.display === "none") { + if (!isModalOpen("settings")) { openSettings(); } } @@ -679,7 +975,7 @@ function addSavePlantumlDocumentEvent() { // == communication == // // send and receive data: { -// sender: string = ["editor"|"url"|"paginator"|"settings"], +// sender: string = ["editor"|"url"|"paginator"|"settings"|"file-drop"], // data: { // encodedDiagram: string | undefined, // index: integer | undefined, diff --git a/src/main/webapp/resource/diagram-export.jsp b/src/main/webapp/resource/diagram-export.jsp new file mode 100644 index 0000000..a6b4d2a --- /dev/null +++ b/src/main/webapp/resource/diagram-export.jsp @@ -0,0 +1,32 @@ + diff --git a/src/main/webapp/resource/diagram-import.jsp b/src/main/webapp/resource/diagram-import.jsp new file mode 100644 index 0000000..e5ea809 --- /dev/null +++ b/src/main/webapp/resource/diagram-import.jsp @@ -0,0 +1,16 @@ + diff --git a/src/main/webapp/resource/preview.jsp b/src/main/webapp/resource/preview.jsp index de66555..648a779 100644 --- a/src/main/webapp/resource/preview.jsp +++ b/src/main/webapp/resource/preview.jsp @@ -32,7 +32,7 @@ id="btn-settings" class="btn-settings" type="image" - src="assets/settings.svg" + src="assets/actions/settings.svg" alt="settings" onclick="openSettings();" /> @@ -40,7 +40,7 @@ id="btn-undock" class="btn-dock" type="image" - src="assets/undock.svg" + src="assets/actions/undock.svg" alt="undock" onclick="undock();" /> @@ -48,7 +48,7 @@ id="btn-dock" class="btn-dock" type="image" - src="assets/dock.svg" + src="assets/actions/dock.svg" alt="dock" onclick="window.close();" style="display: none;" diff --git a/src/main/webapp/resource/settings.jsp b/src/main/webapp/resource/settings.jsp index 31ca747..c0a83c0 100644 --- a/src/main/webapp/resource/settings.jsp +++ b/src/main/webapp/resource/settings.jsp @@ -1,18 +1,18 @@