Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Mermaid pan & zoom #295

Open
wants to merge 30 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
3010999
Added zoom capabilities based on default dimensions
snopan Aug 10, 2024
51ac34b
Added button for enable and disable zoom
snopan Aug 13, 2024
34e69c2
Separate enable zoom and reset view out for dealing with state later
snopan Aug 14, 2024
5cb22b5
Refactor zoom related code to another file
snopan Aug 14, 2024
a5364f5
Added zoom state functionality
snopan Aug 15, 2024
b692f00
Renamed Svgcontent to content as it can be error content
snopan Aug 15, 2024
2640680
Simplifying logic by stopping zoom setup when svg not found in container
snopan Aug 15, 2024
8a2ef03
Move button setup higher along with button creation
snopan Aug 15, 2024
105a28e
Remove throw error because one diagram fails would break other ones
snopan Aug 15, 2024
bf0bd23
Added comments on how zoom states work
snopan Aug 15, 2024
3926d60
Make zoom states not global but a local variable that can be initialized
snopan Aug 15, 2024
7f597b8
Remove old zoom states
snopan Aug 15, 2024
ad42e8d
Create zoom state outside of init() so it can be global
snopan Aug 15, 2024
253ec85
Updated to toggle button
snopan Aug 16, 2024
ff22305
Change references from zoom to pan zoom, added text for toggle
snopan Aug 16, 2024
1c073df
Move toggle button to the left
snopan Aug 16, 2024
519e5e4
Updated toggle and refactored so default pan zoom state would be correct
snopan Aug 16, 2024
9da6347
Update state initialization so it's correct
snopan Aug 16, 2024
36cd79f
Added toggled on color
snopan Aug 26, 2024
2b25044
Merge branch 'master' into feature/toggle-zoom
snopan Nov 13, 2024
db9f978
Toggle only shows when diagram is hovered on
snopan Jan 4, 2025
9dc9dd8
Made pan zoom controls only show on hover
snopan Jan 4, 2025
960537e
Added enable pan zoom config setting
snopan Jan 4, 2025
98e4c9b
Move mermaid container style setup for pan zoom
snopan Jan 4, 2025
bb1c3bc
Update toggle button to use vscode theme colors
snopan Jan 4, 2025
0b4c0ca
Wrapped adding svg to container to a separate function
snopan Jan 4, 2025
21ddd7d
Update comment around svgEl set sizing
snopan Jan 5, 2025
4decb0a
Refactor to simplify index.ts, moved panZoomStates inside zoom.ts to …
snopan Jan 5, 2025
9168cc2
Let pan zoom container take whole width and resize on page resize
snopan Jan 6, 2025
31e23b6
Make diagram reset on resizing
snopan Jan 6, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 9 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
22 changes: 17 additions & 5 deletions src/markdownPreview/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
*/
import mermaid, { MermaidConfig } from 'mermaid';
import { registerMermaidAddons, renderMermaidBlocksInElement } from '../shared-mermaid';
import { renderMermaidBlocksWithPanZoom, resetPanZoom, onResize } 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,
Expand All @@ -20,11 +23,20 @@ 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;
});

// Reset everything as pan zoom has been disabled
resetPanZoom()
}
}

window.addEventListener('resize', onResize)
window.addEventListener('vscode.markdown.updateContent', init);
init();
290 changes: 290 additions & 0 deletions src/markdownPreview/zoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
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 to keep track the state of pan and zoom for each diagram so when
// the markdown preview is refreshed we can turn on pan zoom for diagrams that
// had it on and restore the state to what it was.
// Given we use a simple index to track which diagram is which, if the user
// switches around say diagram 1 with diagram 2 and save then we may end up
// using states for diagram 2 with diagram 1 and vice versa
const panZoomStates: {[index: number]: PanZoomState} = {}

// This is to keep track of all the diagrams that have pan zoom enabled so when
// the page resizes we can loop through all these pan zoom instances and call
// .resize() on all of them
const enabledPanZoomInstances: {[index: number]: SvgPanZoom.Instance} = {}

export async function renderMermaidBlocksWithPanZoom() {

// Add styles to document
document.head.appendChild(getToggleButtonStyles())

// On each re-render we should reset stored pan zoom instances as
// all those old elements should be removed already
resetEnabledPanZoomInstances()

// 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
const panZoomInstance = enablePanZoom(mermaidContainer, svgEl, panZoomState)
enabledPanZoomInstances[index] = panZoomInstance
}

input.onchange = () => {
if (!panZoomState.enabled) {
const panZoomInstance = enablePanZoom(mermaidContainer, svgEl, panZoomState)
enabledPanZoomInstances[index] = panZoomInstance
panZoomState.enabled = true
}
else {
svgEl.remove()
svgEl = addSvgEl(mermaidContainer, content)
delete enabledPanZoomInstances[index]
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)
}

// resetPanZoom clears up all states stored as part of pan zoom functionlaity
// should be used when page is re-rendered with pan zoom turned off
export function resetPanZoom() {
resetPanZoomStates()
resetEnabledPanZoomInstances()
}

// onResize should added as a callback on window resize events
export function onResize() {
resizeEnabledPanZoomInstances()
}

// 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): SvgPanZoom.Instance {

// 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";
svgEl.style.maxWidth = "none";

// 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();
})

return panZoomInstance
}

// 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
}

// 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]
}
}
}

// resetPanZoomStates will remove all stored pan zoom states
function resetPanZoomStates() {
for (var index in panZoomStates) {
if (panZoomStates.hasOwnProperty(index)) {
delete panZoomStates[index]
}
}
}

// resizeEnabledPanZoomInstances will loop through all the currently
// enabled pan zoom instances and call .resize() on them, this should
// be called only on page resizing
function resizeEnabledPanZoomInstances() {
for (var index in enabledPanZoomInstances) {
if (enabledPanZoomInstances.hasOwnProperty(index)) {
const panZoomInstance = enabledPanZoomInstances[index]
panZoomInstance.resize()
panZoomInstance.reset()
}
}
}

// resetEnabledPanZoomInstances will remove all stored enabled pan zoom instaces
function resetEnabledPanZoomInstances() {
for (var index in enabledPanZoomInstances) {
if (enabledPanZoomInstances.hasOwnProperty(index)) {
delete enabledPanZoomInstances[index]
}
}
}

function createPanZoomToggle(mermaidContainer: HTMLElement): HTMLInputElement {
const inputID = `checkbox-${crypto.randomUUID()}`;
mermaidContainer.insertAdjacentHTML("afterbegin", `
<div class="toggle-container">
<input id="${inputID}" class="checkbox" type="checkbox" />
<label class="label" for="${inputID}">
<span class="ball" />
</label>
<div class="text">Pan & Zoom</div>
</div>
`)

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
}
Loading