diff --git a/package-lock.json b/package-lock.json index 4dfddc2..eb39da0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "mini-css-extract-plugin": "^2.2.2", "npm-run-all": "^4.1.5", "style-loader": "^3.2.1", + "svg-pan-zoom": "^3.6.1", "terser-webpack-plugin": "^5.3.6", "ts-loader": "^9.4.2", "typescript": "^5.4.5", @@ -6512,6 +6513,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pan-zoom": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/svg-pan-zoom/-/svg-pan-zoom-3.6.2.tgz", + "integrity": "sha512-JwnvRWfVKw/Xzfe6jriFyfey/lWJLq4bUh2jwoR5ChWQuQoOH8FEh1l/bEp46iHHKHEJWIyFJETbazraxNWECg==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/tailwindcss": { "version": "3.4.14", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.14.tgz", diff --git a/package.json b/package.json index 631e8c8..b63af9b 100644 --- a/package.json +++ b/package.json @@ -72,8 +72,14 @@ "default": "dark", "description": "Default Mermaid theme for dark mode." }, - "markdown-mermaid.languages": { + "markdown-mermaid.enablePanZoom": { "order": 2, + "type": "boolean", + "default": true, + "description": "Enable Pan & Zoom" + }, + "markdown-mermaid.languages": { + "order": 3, "type": "array", "default": [ "mermaid" @@ -105,6 +111,7 @@ "mini-css-extract-plugin": "^2.2.2", "npm-run-all": "^4.1.5", "style-loader": "^3.2.1", + "svg-pan-zoom": "^3.6.1", "terser-webpack-plugin": "^5.3.6", "ts-loader": "^9.4.2", "typescript": "^5.4.5", @@ -113,7 +120,7 @@ "webpack-cli": "^5.0.1" }, "scripts": { - "build-preview": "webpack --mode=production --config ./build/markdownPreview.webpack.config.js", + "build-preview": "webpack --watch --mode=production --config ./build/markdownPreview.webpack.config.js", "build-notebook": "webpack --config ./build/notebook.webpack.config.js", "compile-ext": "webpack --config ./build/webpack.config.js", "watch-ext": "webpack --watch --config ./build/webpack.config.js", diff --git a/src/markdownPreview/index.ts b/src/markdownPreview/index.ts index 36831a5..c2a714b 100644 --- a/src/markdownPreview/index.ts +++ b/src/markdownPreview/index.ts @@ -5,11 +5,14 @@ */ import mermaid, { MermaidConfig } from 'mermaid'; import { registerMermaidAddons, renderMermaidBlocksInElement } from '../shared-mermaid'; +import { renderMermaidBlocksWithPanZoom } from './zoom'; -function init() { +async function init() { const configSpan = document.getElementById('markdown-mermaid'); const darkModeTheme = configSpan?.dataset.darkModeTheme; const lightModeTheme = configSpan?.dataset.lightModeTheme; + const enablePanZoomStr = configSpan?.dataset.enablePanZoom; + const enablePanZoom = enablePanZoomStr ? enablePanZoomStr == "true" : false const config: MermaidConfig = { startOnLoad: false, @@ -20,10 +23,15 @@ function init() { mermaid.initialize(config); registerMermaidAddons(); - - renderMermaidBlocksInElement(document.body, (mermaidContainer, content) => { - mermaidContainer.innerHTML = content; - }); + + + if (enablePanZoom) { + renderMermaidBlocksWithPanZoom() + } else { + renderMermaidBlocksInElement(document.body, (mermaidContainer, content, _) => { + mermaidContainer.innerHTML = content; + }); + } } window.addEventListener('vscode.markdown.updateContent', init); diff --git a/src/markdownPreview/zoom.ts b/src/markdownPreview/zoom.ts new file mode 100644 index 0000000..40ca177 --- /dev/null +++ b/src/markdownPreview/zoom.ts @@ -0,0 +1,230 @@ +import svgPanZoom from 'svg-pan-zoom'; +import { renderMermaidBlocksInElement } from '../shared-mermaid'; + +type PanZoomState = { + requireInit: boolean + enabled: boolean + panX: number + panY: number + scale: number +} + +// This is a map where key is the index of the diagram element and the +// value is it's pan zoom state so when we reconstruct the diagrams we know +// which pan zoom states is for which. There's limitations where if diagrams +// switches places we won't be able to tell. +const panZoomStates: {[index: number]: PanZoomState} = {} + +export async function renderMermaidBlocksWithPanZoom() { + + // Add styles to document + document.head.appendChild(getToggleButtonStyles()) + + // Render each mermaid block with pan zoom capabilities + const numElements = await renderMermaidBlocksInElement(document.body, (mermaidContainer, content, index) => { + + // Setup container styles + mermaidContainer.style.display = "flex"; + mermaidContainer.style.flexDirection = "column"; + + let svgEl = addSvgEl(mermaidContainer, content) + const input = createPanZoomToggle(mermaidContainer) + + // Create an empty pan zoom state if a previous one isn't found + // mark this state as required for initialization which can only + // be set when we enable pan and zoom and know what those values are + let panZoomState = panZoomStates[index] + if (!panZoomState) { + panZoomState = { + requireInit: true, + enabled: false, + panX: 0, + panY: 0, + scale: 0 + } + panZoomStates[index] = panZoomState + } + + // If previously pan & zoom was enabled then re-enable it + if (panZoomState.enabled) { + input.checked = true + enablePanZoom(mermaidContainer, svgEl, panZoomState) + } + + input.onchange = () => { + if (!panZoomState.enabled) { + enablePanZoom(mermaidContainer, svgEl, panZoomState) + panZoomState.enabled = true + } + else { + svgEl.remove() + svgEl = addSvgEl(mermaidContainer, content) + panZoomState.enabled = false + } + } + }); + + // Some diagrams maybe removed during edits and if we have states + // for more diagrams than there are then we should also remove them + removeOldPanZoomStates(numElements) +} + +// removeOldPanZoomStates will remove all pan zoom states where their index +// is larger than the current amount of rendered elements. The usecase is +// if the user creates many diagrams then removes them, we don't want to +// keep pan zoom states for diagrams that don't exist +function removeOldPanZoomStates(numElements: number) { + for (const index in panZoomStates) { + if (Number(index) >= numElements) { + delete panZoomStates[index] + } + } +} + +// addSvgEl inserts the svg content into the provided mermaid container +// then finds the svg element to confirm it is created and returns it +function addSvgEl(mermaidContainer:HTMLElement, content: string): SVGSVGElement { + + // Add svg string content + mermaidContainer.insertAdjacentHTML("beforeend", content) + + // Svg element should be found in container + const svgEl = mermaidContainer.querySelector("svg") + if (!svgEl) throw("svg element not found"); + + return svgEl +} + +// enablePanZoom will modify the provided svgEl with svg-pan-zoom library +// if the provided pan zoom state is new then it will be populated with +// default pan zoom values when the library is initiated. If the pan zoom +// state is not new then it will resync against the pan zoom state +function enablePanZoom(mermaidContainer:HTMLElement, svgEl: SVGElement, panZoomState: PanZoomState) { + + // Svg element doesn't have any width and height defined but relies on auto sizing. + // For svg-pan-zoom to work we need to define atleast the height so we should + // take the current height of the svg + const svgSize = svgEl.getBoundingClientRect() + svgEl.style.height = svgSize.height+"px"; + + // Start up svg-pan-zoom + const panZoomInstance = svgPanZoom(svgEl, { + zoomEnabled: true, + controlIconsEnabled: true, + fit: true, + }); + + // The provided pan zoom state is new and needs to be intialized + // with values once svg-pan-zoom has been started + if (panZoomState.requireInit) { + panZoomState.panX = panZoomInstance.getPan().x + panZoomState.panY = panZoomInstance.getPan().y + panZoomState.scale = panZoomInstance.getZoom() + panZoomState.requireInit = false + + // Otherwise restore pan and zoom to this previous state + } else { + panZoomInstance?.zoom(panZoomState.scale) + panZoomInstance?.pan({ + x: panZoomState.panX, + y: panZoomState.panY, + }) + } + + // Show pan zoom control incons only when mouse hovers over the diagram + mermaidContainer.onmouseenter = (_ => { + panZoomInstance.enableControlIcons() + }) + mermaidContainer.onmouseleave = (_ => { + panZoomInstance.disableControlIcons() + }) + + // Update pan and zoom on any changes + panZoomInstance.setOnUpdatedCTM(_ => { + panZoomState.panX = panZoomInstance.getPan().x; + panZoomState.panY = panZoomInstance.getPan().y; + panZoomState.scale = panZoomInstance.getZoom(); + }) +} + +function createPanZoomToggle(mermaidContainer: HTMLElement): HTMLInputElement { + const inputID = `checkbox-${crypto.randomUUID()}`; + mermaidContainer.insertAdjacentHTML("afterbegin", ` +
+ + +
Pan & Zoom
+
+ `) + + const input = mermaidContainer.querySelector("input") + if (!input) throw Error("toggle input should be defined") + + return input; +} + +function getToggleButtonStyles(): HTMLStyleElement { + const styles = ` + .mermaid:hover .toggle-container { + opacity: 1; + } + + .toggle-container { + opacity: 0; + display: flex; + align-items: center; + margin-bottom: 6px; + transition: opacity 0.2s ease-in-out; + } + + .toggle-container .text { + margin-left: 6px; + font-size: 12px; + cursor: default; + } + + .toggle-container .checkbox { + opacity: 0; + position: absolute; + } + + .toggle-container .label { + background-color: var(--vscode-editorWidget-background); + width: 33px; + height: 19px; + border-radius: 50px; + position: relative; + padding: 5px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + box-sizing: border-box; + } + + .toggle-container .label .ball { + background-color: var(--vscode-editorWidget-foreground); + width: 15px; + height: 15px; + position: absolute; + left: 2px; + top: 1px; + border-radius: 50%; + transition: transform 0.2s linear; + } + + .toggle-container .checkbox:checked + .label .ball { + transform: translateX(14px); + } + + .toggle-container .checkbox:checked + .label { + background-color: var(--vscode-textLink-activeForeground); + } + ` + + const styleSheet = document.createElement("style") + styleSheet.textContent = styles + return styleSheet +} \ No newline at end of file diff --git a/src/shared-mermaid/index.ts b/src/shared-mermaid/index.ts index 2884128..b3aebf9 100644 --- a/src/shared-mermaid/index.ts +++ b/src/shared-mermaid/index.ts @@ -3,10 +3,9 @@ import zenuml from '@mermaid-js/mermaid-zenuml'; import mermaid, { MermaidConfig } from 'mermaid'; import { iconPackConfig, requireIconPack } from './iconPackConfig'; -function renderMermaidElement( - mermaidContainer: HTMLElement, - writeOut: (mermaidContainer: HTMLElement, content: string) => void, -): { +type WriteOutFN = (mermaidContainer: HTMLElement, content: string, index: number) => void + +function renderMermaidElement(mermaidContainer: HTMLElement, index: number, writeOut: WriteOutFN): { containerId: string; p: Promise; } { @@ -26,14 +25,14 @@ function renderMermaidElement( // Render the diagram const renderResult = await mermaid.render(diagramId, source); - writeOut(mermaidContainer, renderResult.svg); + writeOut(mermaidContainer, renderResult.svg, index); renderResult.bindFunctions?.(mermaidContainer); } catch (error) { if (error instanceof Error) { const errorMessageNode = document.createElement('pre'); errorMessageNode.className = 'mermaid-error'; errorMessageNode.innerText = error.message; - writeOut(mermaidContainer, errorMessageNode.outerHTML); + writeOut(mermaidContainer, errorMessageNode.outerHTML, index); } throw error; @@ -42,7 +41,7 @@ function renderMermaidElement( }; } -export async function renderMermaidBlocksInElement(root: HTMLElement, writeOut: (mermaidContainer: HTMLElement, content: string) => void): Promise { +export async function renderMermaidBlocksInElement(root: HTMLElement, writeOut: WriteOutFN): Promise { // Delete existing mermaid outputs for (const el of root.querySelectorAll('.mermaid > svg')) { el.remove(); @@ -55,13 +54,16 @@ export async function renderMermaidBlocksInElement(root: HTMLElement, writeOut: // We need to generate all the container ids sync, but then do the actual rendering async const renderPromises: Array> = []; - for (const mermaidContainer of root.querySelectorAll('.mermaid')) { - renderPromises.push(renderMermaidElement(mermaidContainer, writeOut).p); + const mermaidElements = root.querySelectorAll('.mermaid') + for (let i=0; i) { diff --git a/src/vscode-extension/themeing.ts b/src/vscode-extension/themeing.ts index 2873c68..811c18b 100644 --- a/src/vscode-extension/themeing.ts +++ b/src/vscode-extension/themeing.ts @@ -20,9 +20,11 @@ export function injectMermaidTheme(md: MarkdownIt) { md.renderer.render = function (...args) { const darkModeTheme = sanitizeMermaidTheme(vscode.workspace.getConfiguration(configSection).get('darkModeTheme')); const lightModeTheme = sanitizeMermaidTheme(vscode.workspace.getConfiguration(configSection).get('lightModeTheme')); + const enablePanZoom = vscode.workspace.getConfiguration(configSection).get('enablePanZoom') as Boolean; return ` + data-light-mode-theme="${lightModeTheme}" + data-enable-pan-zoom=${enablePanZoom}> ${render.apply(md.renderer, args)}`; }; return md;