add import and export diagram
- export diagram * add a diagram export dialog where you can choose the file name and download type (code, png, pdf, ...) * set default download type to code * open file save dialog via menu or Ctrl+S (or Meta+S for Mac) - import diagram * similar to [draw.io](https://app.diagrams.net) * open a PlantUML diagram image, use metat data to get diagram code and load this diagram (Note: meta data is currently only supported by PNG and SVG diagram files) * support drag&drop * add diagram import dialog - since three are now multiple options/action -> create a little editor menu - add documentation (including gif examples)
@ -20,3 +20,6 @@ insert_final_newline = false
|
||||
|
||||
[{Dockerfile,Dockerfile.*}]
|
||||
indent_size = 4
|
||||
|
||||
[.vscode/*.json]
|
||||
indent_size = 4
|
||||
|
6
.vscode/settings.json
vendored
@ -12,6 +12,7 @@
|
||||
"Lalloni",
|
||||
"monaco",
|
||||
"plantuml",
|
||||
"puml",
|
||||
"Roques",
|
||||
"servlet",
|
||||
"servlets",
|
||||
@ -20,5 +21,6 @@
|
||||
"undock",
|
||||
"utxt"
|
||||
],
|
||||
"cSpell.allowCompoundWords": true
|
||||
}
|
||||
"cSpell.allowCompoundWords": true,
|
||||
"svg.preview.background": "transparent"
|
||||
}
|
||||
|
BIN
docs/WebUI/gifs/diagram-export.gif
Normal file
After Width: | Height: | Size: 296 KiB |
BIN
docs/WebUI/gifs/diagram-import.gif
Normal file
After Width: | Height: | Size: 1.5 MiB |
21
docs/WebUI/import-export.md
Normal file
@ -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)
|
Before Width: | Height: | Size: 301 B After Width: | Height: | Size: 301 B |
Before Width: | Height: | Size: 384 B After Width: | Height: | Size: 384 B |
1
src/main/webapp/assets/actions/download.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" height="24" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
|
After Width: | Height: | Size: 240 B |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 281 B After Width: | Height: | Size: 281 B |
1
src/main/webapp/assets/actions/upload.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" height="24" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg>
|
After Width: | Height: | Size: 241 B |
@ -36,13 +36,26 @@
|
||||
<div>
|
||||
<div class="btn-input">
|
||||
<input id="url" type="text" name="url" value="png/<%= diagramUrl %>" />
|
||||
<input type="image" alt="copy" src="assets/copy.svg" onclick="copyUrlToClipboard()" />
|
||||
<input type="image" alt="copy" src="assets/actions/copy.svg" onclick="copyUrlToClipboard()" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-main monaco-editor-container">
|
||||
<textarea id="initCode" name="initCode" style="display: none;"><%= net.sourceforge.plantuml.servlet.PlantUmlServlet.stringToHTMLString(decoded) %></textarea>
|
||||
<div id="monaco-editor"></div>
|
||||
<input type="image" alt="copy" src="assets/copy.svg" onclick="copyCodeToClipboard()" />
|
||||
<div class="editor-menu" tabindex="-1">
|
||||
<div class="menu-kebab">
|
||||
<div class="kebab-circle"></div>
|
||||
<div class="kebab-circle"></div>
|
||||
<div class="kebab-circle"></div>
|
||||
<div class="kebab-circle"></div>
|
||||
<div class="kebab-circle"></div>
|
||||
</div>
|
||||
<div class="menu-items">
|
||||
<input class="menu-item" type="image" alt="copy" title="Copy code" src="assets/actions/copy.svg" onclick="copyCodeToClipboard()" />
|
||||
<input class="menu-item" type="image" alt="import" title="Import diagram" src="assets/actions/upload.svg" onclick="openModal('diagram-import')" />
|
||||
<input class="menu-item" type="image" alt="export" title="Export diagram" src="assets/actions/download.svg" onclick="openModal('diagram-export')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="previewer-main-container" class="previewer flex-main">
|
||||
@ -52,6 +65,9 @@
|
||||
<div class="footer">
|
||||
<%@ include file="resource/footer.jsp" %>
|
||||
</div>
|
||||
<!-- editor modals -->
|
||||
<%@ include file="resource/diagram-import.jsp" %>
|
||||
<%@ include file="resource/diagram-export.jsp" %>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
|
32
src/main/webapp/resource/diagram-export.jsp
Normal file
@ -0,0 +1,32 @@
|
||||
<div id="diagram-export" class="modal" style="display: none;" tabindex="-1">
|
||||
<div class="modal-content flex-rows">
|
||||
<div class="modal-header">
|
||||
<h2>Export Diagram</h2>
|
||||
<div class="hr"></div>
|
||||
</div>
|
||||
<div class="modal-main flex-main">
|
||||
<div class="label-input-pair flex-columns">
|
||||
<label for="download-name">Diagram name:</label>
|
||||
<input class="flex-main" id="download-name" value="diagram.puml" />
|
||||
</div>
|
||||
<div class="label-input-pair flex-columns">
|
||||
<label for="download-type">Diagram type:</label>
|
||||
<select class="flex-main" id="download-type" name="download-type">
|
||||
<option value="txt">ASCII Art</option>
|
||||
<option value="base64">Base64</option>
|
||||
<option value="eps">EPS</option>
|
||||
<option value="epstext">EPS Text</option>
|
||||
<option value="map">MAP</option>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="code" selected>PlantUML source code</option>
|
||||
<option value="png">PNG</option>
|
||||
<option value="svg">SVG</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<input id="diagram-export-ok-btn" class="ok" type="button" value="Export" />
|
||||
<input class="cancel" type="button" value="Cancel" onclick="closeModal('diagram-export')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
16
src/main/webapp/resource/diagram-import.jsp
Normal file
@ -0,0 +1,16 @@
|
||||
<div id="diagram-import" class="modal" style="display: none;" tabindex="-1">
|
||||
<div class="modal-content flex-rows">
|
||||
<div class="modal-header">
|
||||
<h2>Import Diagram</h2>
|
||||
<div class="hr"></div>
|
||||
</div>
|
||||
<div class="modal-main flex-main">
|
||||
<input id="diagram-import-input" type="file" name="diagram" onchange="onDiagramImportInputChange(this);" />
|
||||
<p id="diagram-import-error-message" class="error-message"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<input id="diagram-import-ok-btn" class="ok" type="button" value="Import" disabled />
|
||||
<input id="diagram-import-cancel-btn" class="cancel" type="button" value="Cancel" onclick="closeModal('diagram-import');" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -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;"
|
||||
|
@ -1,18 +1,18 @@
|
||||
<div id="settings" class="modal" style="display: none;" tabindex="-1">
|
||||
<div class="modal-content flex-rows">
|
||||
<div class="settings-header">
|
||||
<div class="modal-header">
|
||||
<h2>Settings</h2>
|
||||
<div class="hr"></div>
|
||||
</div>
|
||||
<div class="settings-main flex-main">
|
||||
<div class="setting flex-columns">
|
||||
<div class="modal-main flex-main">
|
||||
<div class="label-input-pair flex-columns">
|
||||
<label for="theme">Theme:</label>
|
||||
<select class="flex-main" id="theme" name="theme">
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting flex-columns">
|
||||
<div class="label-input-pair flex-columns">
|
||||
<label for="diagramPreviewType">Diagram Preview Type:</label>
|
||||
<select class="flex-main" id="diagramPreviewType" name="diagramPreviewType">
|
||||
<option value="png">PNG</option>
|
||||
@ -21,19 +21,19 @@
|
||||
<option value="pdf">PDF</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting flex-columns">
|
||||
<div class="label-input-pair flex-columns">
|
||||
<label for="editorWatcherTimeout">Editor Watcher Timeout:</label>
|
||||
<input class="flex-main" id="editorWatcherTimeout" type="number" pattern="[1-9]+[0-9]*" value="" />
|
||||
</div>
|
||||
<div class="setting flex-main">
|
||||
<div class="label-input-pair flex-main">
|
||||
<label for="editorCreateOptions">Monaco Editor Create Options:</label>
|
||||
<br />
|
||||
<div id="settings-monaco-editor"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-footer">
|
||||
<div class="modal-footer">
|
||||
<input class="ok" type="button" value="Save" onclick="saveSettings();" />
|
||||
<input class="cancel" type="button" value="Cancel" onclick="closeSettings();" />
|
||||
<input class="cancel" type="button" value="Cancel" onclick="closeModal('settings');" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|