From d485ffe03c4f61aa5565cedf3e0f9a394cc059a0 Mon Sep 17 00:00:00 2001 From: Jens Peters Date: Sat, 21 Oct 2023 08:56:00 +0200 Subject: [PATCH] Add pan and zoom for diagrams Introduce modal dialog for diagrams with pan and zoom options. Also move export links to that dialog. Co-authored-by: Declan Lynch --- package.json | 1 + .../site/generatr/site/SiteGenerator.kt | 1 + .../site/generatr/site/views/CDN.kt | 4 ++ .../site/generatr/site/views/Diagram.kt | 52 +++++++++++++++---- .../site/generatr/site/views/Page.kt | 2 + .../site/generatr/site/views/RawHtml.kt | 9 ++-- src/main/resources/assets/css/style.css | 19 +++++++ src/main/resources/assets/js/svg-modal.js | 48 +++++++++++++++++ .../generatr/site/model/MarkdownToHtmlTest.kt | 15 +++++- .../site/generatr/site/views/CDNTest.kt | 1 + 10 files changed, 137 insertions(+), 15 deletions(-) create mode 100644 src/main/resources/assets/js/svg-modal.js diff --git a/package.json b/package.json index 0cdc9004..76e44032 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "lunr": "2.3.9", "lunr-languages": "1.14.0", "mermaid": "10.5.1", + "svg-pan-zoom": "3.6.1", "webfontloader": "1.6.28" } } diff --git a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/SiteGenerator.kt b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/SiteGenerator.kt index 5f8a15cf..e636ed7b 100644 --- a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/SiteGenerator.kt +++ b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/SiteGenerator.kt @@ -14,6 +14,7 @@ import java.security.MessageDigest fun copySiteWideAssets(exportDir: File) { copySiteWideAsset(exportDir, "/css/style.css") copySiteWideAsset(exportDir, "/js/header.js") + copySiteWideAsset(exportDir, "/js/svg-modal.js") copySiteWideAsset(exportDir, "/js/search.js") copySiteWideAsset(exportDir, "/js/auto-reload.js") copySiteWideAsset(exportDir, "/css/admonition.css") diff --git a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/CDN.kt b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/CDN.kt index 20e11255..896de017 100644 --- a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/CDN.kt +++ b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/CDN.kt @@ -47,6 +47,10 @@ class CDN { "${it.baseUrl()}/dist/mermaid.esm.min.mjs" } + fun svgpanzoomJs() = dependencies.single { it.name == "svg-pan-zoom" }.let { + "${it.baseUrl()}/dist/svg-pan-zoom.min.js" + } + fun webfontloaderJs() = dependencies.single { it.name == "webfontloader" }.let { "${it.baseUrl()}/webfontloader.js" } diff --git a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/Diagram.kt b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/Diagram.kt index 270bdd2c..f220d6b6 100644 --- a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/Diagram.kt +++ b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/Diagram.kt @@ -4,26 +4,58 @@ import kotlinx.html.* import nl.avisi.structurizr.site.generatr.site.model.DiagramViewModel fun FlowContent.diagram(viewModel: DiagramViewModel) { - if (viewModel.svg != null) + if (viewModel.svg != null) { + val dialogId = "${viewModel.key}-modal" + val svgId = "${viewModel.key}-svg" + figure { style = "width: min(100%, ${viewModel.diagramWidthInPixels}px);" rawHtml(viewModel.svg) figcaption { - +viewModel.name - +" [" - a(href = viewModel.svgLocation.relativeHref) { +"svg" } - +"|" - a(href = viewModel.pngLocation.relativeHref) { +"png" } - +"|" - a(href = viewModel.pumlLocation.relativeHref) { +"puml" } - +"]" + a { + onClick = "openModal(\"$dialogId\", \"$svgId\")" + +viewModel.name + } } } - else + svgModal(dialogId, svgId, viewModel) + } else div(classes = "notification is-danger") { +"No view with key" span(classes = "has-text-weight-bold") { +" ${viewModel.key} " } +"found!" } } + +private fun FlowContent.svgModal( + dialogId: String, + svgId: String, + viewModel: DiagramViewModel +) { + div(classes = "modal") { + id = dialogId + + div(classes = "modal-background") { + onClick = "closeModal(\"$dialogId\")" + } + div(classes = "modal-content") { + div(classes = "box") { + rawHtml(viewModel.svg!!, svgId, "modal-box-content") + div(classes = "has-text-centered") { + +" [" + a(href = viewModel.svgLocation.relativeHref, target = "_blank") { +"svg" } + +"|" + a(href = viewModel.pngLocation.relativeHref, target = "_blank") { +"png" } + +"|" + a(href = viewModel.pumlLocation.relativeHref, target = "_blank") { +"puml" } + +"]" + } + } + } + button(classes = "modal-close is-large") { + attributes["aria-label"] = "close" + onClick = "closeModal(\"$dialogId\")" + } + } +} diff --git a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/Page.kt b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/Page.kt index 4f4ebd8a..090da5e4 100644 --- a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/Page.kt +++ b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/Page.kt @@ -20,6 +20,8 @@ private fun HTML.headFragment(viewModel: PageViewModel) { link(rel = "stylesheet", href = CDN.bulmaCss()) link(rel = "stylesheet", href = "../" + "/style.css".asUrlToFile(viewModel.url)) link(rel = "stylesheet", href = "./" + "/style-branding.css".asUrlToFile(viewModel.url)) + script(type = ScriptType.textJavaScript, src = "../" + "/svg-modal.js".asUrlToFile(viewModel.url)) { } + script(type = ScriptType.textJavaScript, src = CDN.svgpanzoomJs()) { } if (viewModel.includeTreeview) link(rel = "stylesheet", href = "../" + "/treeview.css".asUrlToFile(viewModel.url)) diff --git a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/RawHtml.kt b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/RawHtml.kt index af1d067f..9f0c48e5 100644 --- a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/RawHtml.kt +++ b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/RawHtml.kt @@ -1,11 +1,12 @@ package nl.avisi.structurizr.site.generatr.site.views -import kotlinx.html.FlowContent -import kotlinx.html.div -import kotlinx.html.unsafe +import kotlinx.html.* -fun FlowContent.rawHtml(html: String) { +fun FlowContent.rawHtml(html: String, contentId: String? = null, contentClass: String? = null) { div { + if (contentId != null) id = contentId + if (contentClass != null) classes = setOf(contentClass) + unsafe { +html } diff --git a/src/main/resources/assets/css/style.css b/src/main/resources/assets/css/style.css index 1add5e00..b5288bde 100644 --- a/src/main/resources/assets/css/style.css +++ b/src/main/resources/assets/css/style.css @@ -21,3 +21,22 @@ svg a:hover { opacity: 90%; } + +.modal-content { + width: calc(100vw - 120px); +} + +.modal-box-content { + width: 100%; + height: calc(100vh - 120px); +} + +.modal-svg { + display: inline; + width: inherit; + min-width: inherit; + max-width: inherit; + height: inherit; + min-height: inherit; + max-height: inherit; +} diff --git a/src/main/resources/assets/js/svg-modal.js b/src/main/resources/assets/js/svg-modal.js new file mode 100644 index 00000000..7c08164c --- /dev/null +++ b/src/main/resources/assets/js/svg-modal.js @@ -0,0 +1,48 @@ +let pz = undefined; + +function resetPz() { + if (pz) { + pz.resize(); + pz.center(); + pz.reset(); + } +} + +function openModal(id, svgId) { + document.getElementById(id).classList.add('is-active') + + const svgElement = document.getElementById(svgId).firstElementChild; + svgElement.classList.add('modal-svg') + + pz = svgPanZoom(svgElement, { + zoomEnabled: true, + controlIconsEnabled: true, + fit: true, + center: true, + minZoom: 1, + maxZoom: 5 + }); + resetPz(); + + // Reset position on window resize + window.addEventListener('resize', resetPz); +} + +function closeModal(id) { + if (pz) { + pz.destroy(); + } + window.removeEventListener('resize', resetPz); + document.getElementById(id).classList.remove('is-active'); +} + +// Add a keyboard event to close all modals +document.addEventListener('keydown', (event) => { + if (event.code === 'Escape') { + (document.querySelectorAll('.modal') || []).forEach((modal) => { + if (modal.classList.contains('is-active')) { + closeModal(modal.id); + } + }); + } +}); diff --git a/src/test/kotlin/nl/avisi/structurizr/site/generatr/site/model/MarkdownToHtmlTest.kt b/src/test/kotlin/nl/avisi/structurizr/site/generatr/site/model/MarkdownToHtmlTest.kt index b874c21b..e4a154d8 100644 --- a/src/test/kotlin/nl/avisi/structurizr/site/generatr/site/model/MarkdownToHtmlTest.kt +++ b/src/test/kotlin/nl/avisi/structurizr/site/generatr/site/model/MarkdownToHtmlTest.kt @@ -263,9 +263,22 @@ class MarkdownToHtmlTest : ViewModelTest() {
- System Landscape Diagram [svg|png|puml] + System Landscape Diagram
+

""".trimIndent() ) diff --git a/src/test/kotlin/nl/avisi/structurizr/site/generatr/site/views/CDNTest.kt b/src/test/kotlin/nl/avisi/structurizr/site/generatr/site/views/CDNTest.kt index 10062564..3cad299c 100644 --- a/src/test/kotlin/nl/avisi/structurizr/site/generatr/site/views/CDNTest.kt +++ b/src/test/kotlin/nl/avisi/structurizr/site/generatr/site/views/CDNTest.kt @@ -18,6 +18,7 @@ class CDNTest { CDN.lunrLanguagesStemmerJs() to "/min/lunr.stemmer.support.min.js", CDN.lunrLanguagesJs("en") to "/min/lunr.en.min.js", CDN.mermaidJs() to "/dist/mermaid.esm.min.mjs", + CDN.svgpanzoomJs() to "/dist/svg-pan-zoom.min.js", CDN.webfontloaderJs() to "/webfontloader.js" ).map { (url, suffix) -> DynamicTest.dynamicTest(url) {