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 20 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
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.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,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 Down
15 changes: 12 additions & 3 deletions src/markdownPreview/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
*/
import mermaid, { MermaidConfig } from 'mermaid';
import { registerMermaidAddons, renderMermaidBlocksInElement } from '../shared-mermaid';
import { getToggleButtonStyles, newPanZoomStates, removeOldPanZoomStates, renderZoomableMermaidBlock } from './zoom';

function init() {
const panZoomStates = newPanZoomStates()

async function init() {
const configSpan = document.getElementById('markdown-mermaid');
const darkModeTheme = configSpan?.dataset.darkModeTheme;
const lightModeTheme = configSpan?.dataset.lightModeTheme;
Expand All @@ -21,9 +24,15 @@ function init() {
mermaid.initialize(config);
registerMermaidAddons();

renderMermaidBlocksInElement(document.body, (mermaidContainer, content) => {
mermaidContainer.innerHTML = content;
document.head.appendChild(getToggleButtonStyles())
const numElements = await renderMermaidBlocksInElement(document.body, (mermaidContainer, content, index) => {
// Setup container styles
mermaidContainer.style.display = "flex"
mermaidContainer.style.flexDirection = "column"

renderZoomableMermaidBlock(mermaidContainer, content, panZoomStates, index)
});
removeOldPanZoomStates(panZoomStates, numElements)
}

window.addEventListener('vscode.markdown.updateContent', init);
Expand Down
194 changes: 194 additions & 0 deletions src/markdownPreview/zoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import svgPanZoom from 'svg-pan-zoom';

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.
type PanZoomStates = {[index: number]: PanZoomState}

export function newPanZoomStates(): PanZoomStates {
return {}
}

export function renderZoomableMermaidBlock(mermaidContainer: HTMLElement, content: string, panZoomStates: PanZoomStates, index: number) {
mermaidContainer.innerHTML = content;

// The content isn't svg so no zoom functionality can be setup
let svgEl = mermaidContainer.querySelector("svg")
if (!svgEl) return;

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: 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(svgEl, panZoomState)
}

input.onchange = () => {
if (!svgEl) throw Error("svg element should be defined")

if (!panZoomState.enabled) {
enablePanZoom(svgEl, panZoomState)
panZoomState.enabled = true
}
else {
svgEl.remove()
mermaidContainer.insertAdjacentHTML("beforeend", content)
svgEl = mermaidContainer.querySelector("svg")
panZoomState.enabled = false
}
}
}

// 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
export function removeOldPanZoomStates(panZoomStates: PanZoomStates, numElements: number) {
for (const index in panZoomStates) {
if (Number(index) >= numElements) {
delete panZoomStates[index]
}
}
}

// 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(svgEl: SVGElement, panZoomState: PanZoomState) {

// After svgPanZoom is applied the auto sizing of svg will not
// work, so we need to define the size to exactly what it is currently
const svgSize = svgEl.getBoundingClientRect()
svgEl.style.width = svgSize.width+"px";
svgEl.style.height = svgSize.height+"px";
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 create initial pan zoom state from the default pan and zoom values
} else {
panZoomInstance?.zoom(panZoomState.scale)
panZoomInstance?.pan({
x: panZoomState.panX,
y: panZoomState.panY,
})
}

// 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", `
<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;
}

export function getToggleButtonStyles(): HTMLStyleElement {
const styles = `
.toggle-container {
display: flex;
align-items: center;
margin-bottom: 6px;
}

.toggle-container .text {
margin-left: 6px;
font-size: 12px;
}

.toggle-container .checkbox {
opacity: 0;
position: absolute;
}

.toggle-container .label {
background-color: #111;
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: #fff;
width: 15px;
height: 15px;
position: absolute;
left: 2px;
top: 2px;
border-radius: 50%;
transition: transform 0.2s linear;
}

.toggle-container .checkbox:checked + .label .ball {
transform: translateX(14px);
}

.toggle-container .checkbox:checked + .label {
background-color: #28a745;
}
`

const styleSheet = document.createElement("style")
styleSheet.textContent = styles
return styleSheet
}
20 changes: 11 additions & 9 deletions src/shared-mermaid/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
} {
Expand All @@ -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;
Expand All @@ -42,7 +41,7 @@ function renderMermaidElement(
};
}

export async function renderMermaidBlocksInElement(root: HTMLElement, writeOut: (mermaidContainer: HTMLElement, content: string) => void): Promise<void> {
export async function renderMermaidBlocksInElement(root: HTMLElement, writeOut: WriteOutFN): Promise<number> {
// Delete existing mermaid outputs
for (const el of root.querySelectorAll('.mermaid > svg')) {
el.remove();
Expand All @@ -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<Promise<void>> = [];
for (const mermaidContainer of root.querySelectorAll<HTMLElement>('.mermaid')) {
renderPromises.push(renderMermaidElement(mermaidContainer, writeOut).p);
const mermaidElements = root.querySelectorAll<HTMLElement>('.mermaid')
for (let i=0; i<mermaidElements.length; i++) {
renderPromises.push(renderMermaidElement(mermaidElements[i], i, writeOut).p);
}

for (const p of renderPromises) {
await p;
}

return mermaidElements.length
}

function registerIconPacks(config: Array<{ prefix?: string; pack: string }>) {
Expand Down