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)
This commit is contained in:
Florian 2023-05-14 09:13:05 +02:00 committed by PlantUML
parent ec7b9f9b1a
commit 6ca582fcb7
18 changed files with 640 additions and 113 deletions

View File

@ -20,3 +20,6 @@ insert_final_newline = false
[{Dockerfile,Dockerfile.*}]
indent_size = 4
[.vscode/*.json]
indent_size = 4

View File

@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View 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)

View File

Before

Width:  |  Height:  |  Size: 301 B

After

Width:  |  Height:  |  Size: 301 B

View File

Before

Width:  |  Height:  |  Size: 384 B

After

Width:  |  Height:  |  Size: 384 B

View 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

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 281 B

After

Width:  |  Height:  |  Size: 281 B

View 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

View File

@ -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>

View File

@ -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 {

View File

@ -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,

View 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>

View 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>

View File

@ -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;"

View File

@ -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>