+
+ <%@ include file="/components/editor/url-input/editor-url-input.jsp" %>
+
+
+
+
+ <%@ include file="/components/editor/menu/editor-menu.jsp" %>
+
+
diff --git a/src/main/webapp/components/editor/menu/editor-menu.css b/src/main/webapp/components/editor/menu/editor-menu.css
new file mode 100644
index 0000000..cdd4bb3
--- /dev/null
+++ b/src/main/webapp/components/editor/menu/editor-menu.css
@@ -0,0 +1,92 @@
+/******************
+* Editor Menu CSS *
+*******************/
+
+.monaco-editor-container .editor-menu {
+ position: absolute;
+ right: 0;
+ top: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ flex: 1;
+}
+.monaco-editor-container .editor-menu > div.menu-kebab {
+ width: 60px;
+ height: 60px;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ align-items: center;
+ cursor: pointer;
+ scale: 0.5;
+}
+.monaco-editor-container .editor-menu:hover > div.menu-kebab,
+.monaco-editor-container .editor-menu:focus > div.menu-kebab {
+ outline: none;
+ scale: 0.65;
+}
+.monaco-editor-container .menu-kebab .kebab-circle {
+ width: 12px;
+ height: 12px;
+ margin: 3px;
+ background: var(--font-color);
+ border-radius: 50%;
+ display: block;
+ opacity: 0.8;
+}
+.monaco-editor-container .menu-kebab {
+ flex-direction: column;
+ position: relative;
+ transition: all 300ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
+}
+.monaco-editor-container .menu-kebab .kebab-circle:nth-child(4),
+.monaco-editor-container .menu-kebab .kebab-circle:nth-child(5) {
+ position: absolute;
+ opacity: 0;
+ top: 50%;
+ margin-top: -6px;
+ left: 50%;
+}
+.monaco-editor-container .menu-kebab .kebab-circle:nth-child(4) {
+ margin-left: -25px;
+}
+.monaco-editor-container .menu-kebab .kebab-circle:nth-child(5) {
+ margin-left: 13px;
+}
+.monaco-editor-container .editor-menu:hover .menu-kebab,
+.monaco-editor-container .editor-menu:focus .menu-kebab {
+ transform: rotate(45deg);
+}
+.monaco-editor-container .editor-menu:hover .menu-kebab .kebab-circle,
+.monaco-editor-container .editor-menu:focus .menu-kebab .kebab-circle {
+ opacity: 1;
+}
+
+.monaco-editor-container .editor-menu .menu-item {
+ display: none;
+ margin: 1rem 0;
+ height: 1.75rem;
+ opacity: 0.5;
+ position: relative;
+ -webkit-animation-name: editor-menu-animateitem;
+ -webkit-animation-duration: 0.4s;
+ animation-name: editor-menu-animateitem;
+ animation-duration: 0.4s;
+}
+@-webkit-keyframes editor-menu-animateitem {
+ from { top: -50%; opacity: 0; }
+ to { top: 0; opacity: 0.5; }
+}
+@keyframes editor-menu-animateitem {
+ from { top: -50%; opacity: 0; }
+ to { top: 0; opacity: 0.5; }
+}
+.monaco-editor-container .editor-menu .menu-item:hover {
+ opacity: 1;
+}
+.monaco-editor-container .editor-menu:hover .menu-item,
+.monaco-editor-container .editor-menu:focus .menu-item {
+ display: block;
+}
diff --git a/src/main/webapp/components/editor/menu/editor-menu.js b/src/main/webapp/components/editor/menu/editor-menu.js
new file mode 100644
index 0000000..cc633e1
--- /dev/null
+++ b/src/main/webapp/components/editor/menu/editor-menu.js
@@ -0,0 +1,15 @@
+/*****************
+* Editor Menu JS *
+******************/
+
+function initEditorMenu() {
+ function copyCodeToClipboard() {
+ const range = document.editor.getModel().getFullModelRange();
+ document.editor.focus();
+ document.editor.setSelection(range);
+ const code = document.editor.getValue();
+ navigator.clipboard?.writeText(code).catch(() => {});
+ }
+ // add listener
+ document.getElementById("menu-item-editor-code-copy").addEventListener("click", copyCodeToClipboard);
+}
diff --git a/src/main/webapp/components/editor/menu/editor-menu.jsp b/src/main/webapp/components/editor/menu/editor-menu.jsp
new file mode 100644
index 0000000..c80ca28
--- /dev/null
+++ b/src/main/webapp/components/editor/menu/editor-menu.jsp
@@ -0,0 +1,35 @@
+
diff --git a/src/main/webapp/components/editor/url-input/editor-url-input.css b/src/main/webapp/components/editor/url-input/editor-url-input.css
new file mode 100644
index 0000000..440c986
--- /dev/null
+++ b/src/main/webapp/components/editor/url-input/editor-url-input.css
@@ -0,0 +1,29 @@
+/***********************
+* Editor URL Input CSS *
+************************/
+
+.editor .btn-input {
+ align-items: center;
+ border-bottom: 3px solid var(--border-color);
+ box-sizing: border-box;
+ display: flex;
+ justify-content: center;
+}
+.editor .btn-input input[type=text] {
+ border: 0;
+ flex: 1 1 1px;
+ font-family: monospace;
+ font-size: medium;
+ padding: 0.2em;
+ text-overflow: ellipsis;
+}
+.editor .btn-input input[type=text]:focus {
+ border: 0;
+ box-shadow: none;
+ outline: none;
+}
+.editor .btn-input input[type="image"] {
+ height: 1rem;
+ margin-left: 0.7em;
+ padding: 0 0.3em;
+}
diff --git a/src/main/webapp/components/editor/url-input/editor-url-input.js b/src/main/webapp/components/editor/url-input/editor-url-input.js
new file mode 100644
index 0000000..6973ecc
--- /dev/null
+++ b/src/main/webapp/components/editor/url-input/editor-url-input.js
@@ -0,0 +1,53 @@
+/**********************
+* Editor URL Input JS *
+***********************/
+
+const { setUrlValue, initEditorUrlInput } = (function() {
+ function setUrlValue(
+ url=undefined,
+ { encodedDiagram=undefined, index=undefined } = {},
+ { suppressEditorChangedMessage=false } = {}
+ ) {
+ if (!url && !encodedDiagram) return;
+ if (suppressEditorChangedMessage) {
+ suppressNextMessage("url");
+ }
+ document.getElementById("url").value = url ? url : resolvePath(buildUrl("png", encodedDiagram, index));
+ }
+
+ function initEditorUrlInput() {
+ const input = document.getElementById("url");
+
+ function copyUrlToClipboard() {
+ input.focus();
+ input.select();
+ navigator.clipboard?.writeText(input.value).catch(() => {});
+ }
+ async function onInputChanged(event) {
+ document.appConfig.autoRefreshState = "started";
+ event.target.title = event.target.value;
+ const analysedUrl = analyseUrl(event.target.value);
+ // decode diagram (server request)
+ const code = await makeRequest("GET", "coder/" + analysedUrl.encodedDiagram);
+ // change editor content without sending the editor change message
+ setEditorValue(document.editor, code, { suppressEditorChangedMessage: true });
+ sendMessage({
+ sender: "url",
+ data: {
+ encodedDiagram: analysedUrl.encodedDiagram,
+ index: analysedUrl.index,
+ },
+ synchronize: true,
+ });
+ }
+
+ // resolve relative path inside url input once
+ setUrlValue(resolvePath(input.value));
+ // update editor and everything else if the URL input is changed
+ input.addEventListener("change", onInputChanged);
+ // add listener
+ document.getElementById("url-copy-btn").addEventListener("click", copyUrlToClipboard);
+ }
+
+ return { setUrlValue, initEditorUrlInput };
+})();
diff --git a/src/main/webapp/components/editor/url-input/editor-url-input.jsp b/src/main/webapp/components/editor/url-input/editor-url-input.jsp
new file mode 100644
index 0000000..f3d765b
--- /dev/null
+++ b/src/main/webapp/components/editor/url-input/editor-url-input.jsp
@@ -0,0 +1,4 @@
+
+ <%@ include file="/components/preview/menu/preview-menu.jsp" %>
+
+ <%@ include file="/components/preview/paginator/paginator.jsp" %>
+
+
+ <%@ include file="/components/preview/diagram/preview-diagram.jsp" %>
+
+ <% if (showSocialButtons) { %>
+
+ <%@ include file="/components/preview/social-buttons.jsp" %>
+
+ <% } %>
+
+ <%@ include file="/components/modals/settings/settings.jsp" %>
+
diff --git a/src/main/webapp/resource/socialbuttons2.jsp b/src/main/webapp/components/preview/social-buttons.jsp
similarity index 100%
rename from src/main/webapp/resource/socialbuttons2.jsp
rename to src/main/webapp/components/preview/social-buttons.jsp
diff --git a/src/main/webapp/index.jsp b/src/main/webapp/index.jsp
index 7732650..6506a1b 100644
--- a/src/main/webapp/index.jsp
+++ b/src/main/webapp/index.jsp
@@ -16,58 +16,26 @@
- <%@ include file="resource/htmlheadbase.jsp" %>
+ <%@ include file="/components/app-head.jsp" %>
-
+ <%@ include file="/components/editor/editor.jsp" %>
- <%@ include file="resource/preview.jsp" %>
+ <%@ include file="/components/preview/preview.jsp" %>
- <%@ include file="resource/diagram-import.jsp" %>
- <%@ include file="resource/diagram-export.jsp" %>
+ <%@ include file="/components/modals/diagram-import/diagram-import.jsp" %>
+ <%@ include file="/components/modals/diagram-export/diagram-export.jsp" %>
diff --git a/src/main/webapp/js/communication/browser.js b/src/main/webapp/js/communication/browser.js
new file mode 100644
index 0000000..0c8ee2d
--- /dev/null
+++ b/src/main/webapp/js/communication/browser.js
@@ -0,0 +1,123 @@
+/************************
+* Browser Communication *
+*************************
+* send and receive data object:
+* {
+* sender: string = ["editor"|"url"|"paginator"|"settings"|"file-drop"],
+* data: {
+* encodedDiagram: string | undefined,
+* index: integer | undefined,
+* numberOfDiagramPages: integer | undefined,
+* appConfig: object | undefined
+* } | undefined,
+* synchronize: boolean = false,
+* reload: boolean = false, // reload page
+* force: boolean = false // force synchronize or reload
+* }
+*************************/
+
+const { sendMessage, suppressNextMessage, initAppCommunication } = (function() {
+ const BROADCAST_CHANNEL = "plantuml-server";
+
+ const { suppressNextMessage, isMessageSuppressed } = (function() {
+ const suppressMessages = [];
+ function suppressNextMessage(sender, condition=undefined) {
+ suppressMessages.push({ sender, condition });
+ }
+ function isMessageSuppressed(data) {
+ for (let i = 0; i < suppressMessages.length; i++) {
+ const suppressMessage = suppressMessages[i];
+ if (!suppressMessage.sender || suppressMessage.sender === data.sender) {
+ if (!suppressMessage.condition || suppressMessage.condition(data)) {
+ suppressMessages.splice(i, 1);
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+ return { suppressNextMessage, isMessageSuppressed };
+ })();
+
+ function sendMessage(data) {
+ if (isMessageSuppressed(data)) return;
+ (new BroadcastChannel(BROADCAST_CHANNEL)).postMessage(data);
+ }
+
+ function initAppCommunication() {
+ function updateReceiveMessageData(data) {
+ if (!data || Object.keys(data).length === 0) return {};
+
+ const changedFlags = {};
+ if ("encodedDiagram" in data && data.encodedDiagram !== document.appData.encodedDiagram) {
+ document.appData.encodedDiagram = data.encodedDiagram;
+ changedFlags.diagram = true;
+ }
+ if ("index" in data && data.index !== document.appData.index) {
+ document.appData.index = data.index;
+ changedFlags.index = true;
+ }
+ if ("numberOfDiagramPages" in data && data.numberOfDiagramPages !== document.appData.numberOfDiagramPages) {
+ document.appData.numberOfDiagramPages = data.numberOfDiagramPages;
+ changedFlags.numberOfDiagramPages = true;
+ }
+ if ("appConfig" in data && data.appConfig !== document.appConfig) {
+ document.appConfig = data.appConfig;
+ changedFlags.appConfig = true;
+ }
+ return changedFlags;
+ }
+
+ async function receiveMessage(event) {
+ async function updateStaticPageData(sender) {
+ document.appConfig.autoRefreshState = "syncing";
+ const encodedDiagram = document.appData.encodedDiagram;
+ const index = document.appData.index;
+
+ if (sender !== "url" && document.getElementById("url")) {
+ // update URL input
+ setUrlValue(undefined, { encodedDiagram, index }, { suppressEditorChangedMessage: true });
+ }
+ // update diagram image
+ await setDiagram(document.appConfig.diagramPreviewType, encodedDiagram, index);
+ // update external diagram links
+ for (let target of document.getElementsByClassName("diagram-link")) {
+ target.href = buildUrl(target.dataset.imgType, encodedDiagram, index);
+ }
+ // update browser url as well as the browser history
+ const url = replaceUrl(window.location.href, encodedDiagram, index).url;
+ history.replaceState(history.stat, document.title, url);
+
+ // set auto refresh state to complete
+ document.appConfig.autoRefreshState = "complete";
+ }
+
+ const data = event.data.data;
+ const force = event.data.force || false;
+ const changedFlags = updateReceiveMessageData(data);
+ if (event.data.synchronize === true) {
+ if (force || changedFlags.diagram || changedFlags.index || changedFlags.appConfig) {
+ await updateStaticPageData(event.data.sender);
+ }
+ if (force || changedFlags.numberOfDiagramPages) {
+ updatePaginator();
+ }
+ if (force || changedFlags.numberOfDiagramPages || changedFlags.index) {
+ updatePaginatorSelection();
+ }
+ if (changedFlags.appConfig) {
+ applyConfig();
+ }
+ }
+ if (event.data.reload === true) {
+ window.location.reload();
+ }
+ }
+
+ // create broadcast channel
+ const bc = new BroadcastChannel(BROADCAST_CHANNEL);
+ bc.onmessage = receiveMessage;
+ }
+
+ return { sendMessage, suppressNextMessage, initAppCommunication };
+})();
diff --git a/src/main/webapp/js/communication/server.js b/src/main/webapp/js/communication/server.js
new file mode 100644
index 0000000..09d9e54
--- /dev/null
+++ b/src/main/webapp/js/communication/server.js
@@ -0,0 +1,20 @@
+/***********************
+* Server Communication *
+************************/
+
+function makeRequest(
+ method,
+ url,
+ {
+ data = null,
+ headers = { "Content-Type": "text/plain" },
+ responseType = "text",
+ baseUrl = "",
+ } = {}
+) {
+ return PlantUmlLanguageFeatures.makeRequest(
+ method,
+ url,
+ { data, headers, responseType, baseUrl }
+ );
+}
diff --git a/src/main/webapp/js/config/config.js b/src/main/webapp/js/config/config.js
new file mode 100644
index 0000000..ac1edf5
--- /dev/null
+++ b/src/main/webapp/js/config/config.js
@@ -0,0 +1,45 @@
+/*****************
+* Configurations *
+******************/
+
+const { applyConfig, updateConfig } = (function() {
+ const DEFAULT_APP_CONFIG = {
+ changeEventsEnabled: true,
+ // `autoRefreshState` is mostly used for unit testing puposes.
+ // states: disabled | waiting | started | syncing | complete
+ autoRefreshState: "disabled",
+ theme: undefined, // dark | light (will be set via `initTheme` if undefined)
+ diagramPreviewType: "png",
+ editorWatcherTimeout: 500,
+ editorCreateOptions: {
+ automaticLayout: true,
+ fixedOverflowWidgets: true,
+ minimap: { enabled: false },
+ scrollbar: { alwaysConsumeMouseWheel: false },
+ scrollBeyondLastLine: false,
+ tabSize: 2,
+ theme: "vs", // "vs-dark"
+ }
+ };
+
+ function applyConfig() {
+ setTheme(document.appConfig.theme);
+ document.editor?.updateOptions(document.appConfig.editorCreateOptions);
+ document.settingsEditor?.updateOptions(document.appConfig.editorCreateOptions);
+ }
+ function updateConfig(appConfig) {
+ localStorage.setItem("document.appConfig", JSON.stringify(appConfig));
+ sendMessage({
+ sender: "config",
+ data: { appConfig },
+ synchronize: true,
+ });
+ }
+
+ document.appConfig = Object.assign({}, window.opener?.document.appConfig);
+ if (Object.keys(document.appConfig).length === 0) {
+ document.appConfig = JSON.parse(localStorage.getItem("document.appConfig")) || DEFAULT_APP_CONFIG;
+ }
+
+ return { applyConfig, updateConfig };
+})();
diff --git a/src/main/webapp/js/language/completion/emojis.js b/src/main/webapp/js/language/completion/emojis.js
new file mode 100644
index 0000000..38caccf
--- /dev/null
+++ b/src/main/webapp/js/language/completion/emojis.js
@@ -0,0 +1,55 @@
+/**********************************************
+* PlantUML Language Emoji Completion Provider *
+***********************************************/
+
+PlantUmlLanguageFeatures.prototype.getEmojis = (function(){
+ let emojis = undefined;
+ return async function() {
+ if (emojis === undefined) {
+ emojis = await PlantUmlLanguageFeatures.makeRequest("GET", "ui-helper?request=emojis");
+ }
+ return emojis;
+ }
+})();
+
+PlantUmlLanguageFeatures.prototype.registerEmojiCompletion = function() {
+ const createEmojiProposals = async (range, filter = undefined) => {
+ const emojis = await this.getEmojis();
+ return emojis?.filter(([unicode, name]) => filter ? unicode.includes(filter) || name?.includes(filter) : true)
+ .map(([unicode, name]) => {
+ // NOTE: load images direct from GitHub source: https://github.com/twitter/twemoji#download
+ const emojiUrl = "https://raw.githubusercontent.com/twitter/twemoji/gh-pages/v/13.1.0/svg/" + unicode + ".svg";
+ const docHint = (name) ? name + " (" + unicode + ")" : unicode;
+ const isUnicode = !name || (filter && unicode.includes(filter));
+ const label = isUnicode ? unicode : name;
+ return {
+ label: label,
+ kind: monaco.languages.CompletionItemKind.Constant,
+ documentation: {
+ //supportHtml: true, // also a possibility but quite limited html
+ value: "![emoji](" + emojiUrl + ") " + docHint
+ },
+ insertText: label + ":>",
+ range: range
+ };
+ }) || [];
+ };
+
+ monaco.languages.registerCompletionItemProvider(PlantUmlLanguageFeatures.languageSelector, {
+ triggerCharacters: [":"],
+ provideCompletionItems: async (model, position) => {
+ const textUntilPosition = model.getValueInRange({
+ startLineNumber: position.lineNumber,
+ startColumn: 1,
+ endLineNumber: position.lineNumber,
+ endColumn: position.column,
+ });
+ const match = textUntilPosition.match(/<:([^\s>]*)$/);
+ if (match) {
+ const suggestions = await createEmojiProposals(this.getWordRange(model, position), match[1]);
+ return { suggestions };
+ }
+ return { suggestions: [] };
+ }
+ });
+};
diff --git a/src/main/webapp/js/language/completion/icons.js b/src/main/webapp/js/language/completion/icons.js
new file mode 100644
index 0000000..76c8d33
--- /dev/null
+++ b/src/main/webapp/js/language/completion/icons.js
@@ -0,0 +1,54 @@
+/*********************************************
+* PlantUML Language Icon Completion Provider *
+**********************************************/
+
+PlantUmlLanguageFeatures.prototype.getIcons = (function(){
+ let icons = undefined;
+ return async function() {
+ if (icons === undefined) {
+ icons = await PlantUmlLanguageFeatures.makeRequest("GET", "ui-helper?request=icons");
+ }
+ return icons;
+ }
+})();
+
+PlantUmlLanguageFeatures.prototype.registerIconCompletion = function() {
+ const createIconProposals = async (range, filter = undefined) => {
+ const icons = await this.getIcons();
+ return icons?.filter(icon => filter ? icon.includes(filter) : true)
+ .map(icon => {
+ // NOTE: markdown image path inside suggestions seems to have rendering issues while using relative paths
+ const iconUrl = PlantUmlLanguageFeatures.absolutePath(
+ PlantUmlLanguageFeatures.baseUrl + "ui-helper?request=icons.svg#" + icon
+ );
+ return {
+ label: icon,
+ kind: monaco.languages.CompletionItemKind.Constant,
+ documentation: {
+ //supportHtml: true, // also a possibility but quite limited html
+ value: "![icon](" + iconUrl + ") " + icon
+ },
+ insertText: icon + ">",
+ range: range
+ };
+ }) || [];
+ };
+
+ monaco.languages.registerCompletionItemProvider(PlantUmlLanguageFeatures.languageSelector, {
+ triggerCharacters: ["&"],
+ provideCompletionItems: async (model, position) => {
+ const textUntilPosition = model.getValueInRange({
+ startLineNumber: position.lineNumber,
+ startColumn: 1,
+ endLineNumber: position.lineNumber,
+ endColumn: position.column,
+ });
+ const match = textUntilPosition.match(/<&([^\s>]*)$/);
+ if (match) {
+ const suggestions = await createIconProposals(this.getWordRange(model, position), match[1]);
+ return { suggestions };
+ }
+ return { suggestions: [] };
+ }
+ });
+};
diff --git a/src/main/webapp/js/language/completion/themes.js b/src/main/webapp/js/language/completion/themes.js
new file mode 100644
index 0000000..ca680e2
--- /dev/null
+++ b/src/main/webapp/js/language/completion/themes.js
@@ -0,0 +1,58 @@
+/**********************************************
+* PlantUML Language Theme Completion Provider *
+***********************************************/
+
+PlantUmlLanguageFeatures.prototype.getThemes = (function(){
+ let themes = undefined;
+ return async function() {
+ if (themes === undefined) {
+ themes = await PlantUmlLanguageFeatures.makeRequest("GET", "ui-helper?request=themes");
+ }
+ return themes;
+ }
+})();
+
+PlantUmlLanguageFeatures.prototype.registerThemeCompletion = function() {
+ const createThemeProposals = async (range, filter = undefined) => {
+ const themes = await this.getThemes();
+ return themes?.filter(theme => filter ? theme.includes(filter) : true)
+ .map(theme => ({
+ label: theme,
+ kind: monaco.languages.CompletionItemKind.Text,
+ documentation: "PlantUML " + theme + " theme",
+ insertText: theme,
+ range: range,
+ })) || [];
+ };
+
+ monaco.languages.registerCompletionItemProvider(PlantUmlLanguageFeatures.languageSelector, {
+ triggerCharacters: [" "],
+ provideCompletionItems: async (model, position) => {
+ const textUntilPosition = model.getValueInRange({
+ startLineNumber: position.lineNumber,
+ startColumn: 1,
+ endLineNumber: position.lineNumber,
+ endColumn: position.column,
+ });
+ if (textUntilPosition.match(/^\s*!(t(h(e(m(e)?)?)?)?)?$/)) {
+ return {
+ suggestions: [
+ {
+ label: 'theme',
+ kind: monaco.languages.CompletionItemKind.Keyword,
+ documentation: "PlantUML theme command",
+ insertText: 'theme',
+ range: this.getWordRange(model, position),
+ }
+ ]
+ };
+ }
+ const match = textUntilPosition.match(/^\s*!theme\s+([^\s]*)$/);
+ if (match) {
+ const suggestions = await createThemeProposals(this.getWordRange(model, position), match[1]);
+ return { suggestions };
+ }
+ return { suggestions: [] };
+ }
+ });
+};
diff --git a/src/main/webapp/js/language/completion/utils.js b/src/main/webapp/js/language/completion/utils.js
new file mode 100644
index 0000000..ca55b19
--- /dev/null
+++ b/src/main/webapp/js/language/completion/utils.js
@@ -0,0 +1,13 @@
+/**********************************************
+* PlantUML Language Completion Provider Utils *
+***********************************************/
+
+PlantUmlLanguageFeatures.prototype.getWordRange = function(model, position) {
+ const word = model.getWordUntilPosition(position);
+ return {
+ startLineNumber: position.lineNumber,
+ endLineNumber: position.lineNumber,
+ startColumn: word.startColumn,
+ endColumn: word.endColumn,
+ };
+}
diff --git a/src/main/webapp/js/language/language.js b/src/main/webapp/js/language/language.js
new file mode 100644
index 0000000..5ea370a
--- /dev/null
+++ b/src/main/webapp/js/language/language.js
@@ -0,0 +1,92 @@
+/************************************************
+* Monaco Editor PlantUML Language Features Base *
+*************************************************/
+"use strict";
+
+/**
+ * Monaco Editor PlantUML Language Features.
+ *
+ * @param {boolean} [initialize] `true` if all default validation and code completion
+ * functions should be activated; otherwise `false`
+ *
+ * @example
+ * ```js
+ * plantumlFeatures = new PlantUmlLanguageFeatures();
+ * const model = monaco.editor.createModel(initCode, "apex", uri);
+ * model.onDidChangeContent(() => plantumlFeatures.validateCode(model));
+ * ```
+ */
+const PlantUmlLanguageFeatures = function(initialize = true) {
+ if (initialize) {
+ // initialize all validation and code completion methods
+ this.addStartEndValidationListeners();
+ this.registerThemeCompletion();
+ this.registerIconCompletion();
+ this.registerEmojiCompletion();
+ }
+};
+
+PlantUmlLanguageFeatures.baseUrl = "";
+PlantUmlLanguageFeatures.setBaseUrl = function(baseUrl) {
+ if (baseUrl === null || baseUrl === undefined) {
+ baseUrl = "";
+ } else if (baseUrl !== "") {
+ if (baseUrl.slice(-1) !== "/") {
+ baseUrl = baseUrl + "/"; // add tailing "/"
+ }
+ }
+ PlantUmlLanguageFeatures.baseUrl = baseUrl;
+}
+
+PlantUmlLanguageFeatures.languageSelector = ["apex", "plantuml"];
+PlantUmlLanguageFeatures.setLanguageSelector = function(languageSelector) {
+ PlantUmlLanguageFeatures.languageSelector = languageSelector;
+}
+
+PlantUmlLanguageFeatures.makeRequest = function(
+ method,
+ url,
+ {
+ data = null,
+ headers = { "Content-Type": "text/plain" },
+ responseType = "json",
+ baseUrl = PlantUmlLanguageFeatures.baseUrl,
+ } = {}
+) {
+ function getResolveResponse(xhr) {
+ return responseType === "json" ? xhr.response : xhr.responseText;
+ }
+ function getRejectResponse(xhr) {
+ return responseType === "json"
+ ? { status: xhr.status, response: xhr.response }
+ : { status: xhr.status, responseText: xhr.responseText };
+ }
+ const targetUrl = !baseUrl ? url : baseUrl.replace(/\/*$/g, "/") + url;
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.onreadystatechange = function() {
+ if (xhr.readyState === XMLHttpRequest.DONE) {
+ if (xhr.status >= 200 && xhr.status <= 300) {
+ resolve(getResolveResponse(xhr));
+ } else {
+ reject(getRejectResponse(xhr));
+ }
+ }
+ }
+ xhr.open(method, targetUrl, true);
+ xhr.responseType = responseType;
+ headers && Object.keys(headers).forEach(key => xhr.setRequestHeader(key, headers[key]));
+ xhr.send(data);
+ });
+}
+
+PlantUmlLanguageFeatures.absolutePath = function(path) {
+ if (path.startsWith("http")) return path;
+ if (path.startsWith("//")) return window.location.protocol + path;
+ if (path.startsWith("/")) return window.location.origin + path;
+
+ if (path.slice(0, 2) == "./") path = path.slice(2);
+ let base = (document.querySelector("base") || {}).href || window.location.origin;
+ if (base.slice(-1) == "/") base = base.slice(0, -1);
+ return base + "/" + path;
+}
diff --git a/src/main/webapp/js/language/validation/listeners/start-end-validation.js b/src/main/webapp/js/language/validation/listeners/start-end-validation.js
new file mode 100644
index 0000000..9a70eae
--- /dev/null
+++ b/src/main/webapp/js/language/validation/listeners/start-end-validation.js
@@ -0,0 +1,103 @@
+/****************************************
+* Language Start-End Validation Feature *
+*****************************************/
+
+/**
+ * Add PlantUML `@start` and `@end` command validation.
+ */
+PlantUmlLanguageFeatures.prototype.addStartEndValidationListeners = function() {
+ let diagramType = undefined;
+ let startCounter = 0;
+ let endCounter = 0;
+
+ // reset validation cache
+ this.addValidationEventListener("before", () => {
+ diagramType = undefined;
+ startCounter = 0;
+ endCounter = 0;
+ });
+
+ // @start should be the first command
+ this.addValidationEventListener("code", ({ model, code }) => {
+ const match = code.match(/^(?:(?:'.*)|\s)*@start(\w+)/);
+ if (match) {
+ diagramType = match[1];
+ return; // diagram code starts with a `@start`
+ }
+ return {
+ message: "PlantUML diagrams should begin with the `@start` command and `@start` should also be the first command.",
+ severity: monaco.MarkerSeverity.Warning,
+ startLineNumber: 1,
+ startColumn: 1,
+ endLineNumber: 1,
+ endColumn: model.getLineLength(1) + 1,
+ };
+ });
+
+ // @end should be the last command and should be of the same type (e.g. @startjson ... @endjson)
+ this.addValidationEventListener("code", ({ model, code }) => {
+ const lineCount = model.getLineCount();
+ const match = code.match(/\s+@end(\w+)(?:(?:'.*)|\s)*$/);
+ if (match) {
+ if (diagramType === match[1]) {
+ return; // diagram code ends with a `@end` of the same type as the `@start`
+ }
+ return {
+ message: "PlantUML diagrams should start and end with the type.\nExample: `@startjson ... @endjson`",
+ severity: monaco.MarkerSeverity.Error,
+ startLineNumber: lineCount,
+ startColumn: 1,
+ endLineNumber: lineCount,
+ endColumn: model.getLineLength(lineCount) + 1,
+ };
+ }
+ return {
+ message: "PlantUML diagrams should end with the `@end` command and `@end` should also be the last command.",
+ severity: monaco.MarkerSeverity.Warning,
+ startLineNumber: lineCount,
+ startColumn: 1,
+ endLineNumber: lineCount,
+ endColumn: model.getLineLength(lineCount) + 1,
+ };
+ });
+
+ // @start should only be used once
+ this.addValidationEventListener("line", ({ range, line }) => {
+ const match = line.match(/^\s*@start(\w+)(?:\s+.*)?$/);
+ if (!match) return;
+
+ startCounter += 1;
+ if (startCounter > 1) {
+ const word = "@start" + match[1];
+ const wordIndex = line.indexOf(word);
+ return {
+ message: "Multiple @start commands detected.",
+ severity: monaco.MarkerSeverity.Warning,
+ startLineNumber: range.startLineNumber,
+ startColumn: wordIndex + 1,
+ endLineNumber: range.endLineNumber,
+ endColumn: wordIndex + word.length + 1,
+ };
+ }
+ });
+
+ // @end should only be used once
+ this.addValidationEventListener("line", ({ range, line }) => {
+ const match = line.match(/^\s*@end(\w+)(?:\s+.*)?$/);
+ if (!match) return;
+
+ endCounter += 1;
+ if (endCounter > 1) {
+ const word = "@end" + match[1];
+ const wordIndex = line.indexOf(word);
+ return {
+ message: "Multiple @end commands detected.",
+ severity: monaco.MarkerSeverity.Warning,
+ startLineNumber: range.startLineNumber,
+ startColumn: wordIndex + 1,
+ endLineNumber: range.endLineNumber,
+ endColumn: wordIndex + word.length + 1,
+ };
+ }
+ });
+};
diff --git a/src/main/webapp/js/language/validation/validation.js b/src/main/webapp/js/language/validation/validation.js
new file mode 100644
index 0000000..98b0115
--- /dev/null
+++ b/src/main/webapp/js/language/validation/validation.js
@@ -0,0 +1,73 @@
+/********************************************
+* PlantUML Language Validation Feature Base *
+*********************************************/
+
+(function() {
+
+ const validationEventListeners = {};
+
+ /**
+ * Add validation event listener.
+ *
+ * Validation Event Order:
+ * before -> code -> line -> after
+ *
+ * @param {("before"|"code"|"line"|"after")} type before|code|line|after event type
+ * @param {(event: any) => Promise