Skip to content

Commit 8edaf26

Browse files
authored
Merge pull request #873 from dannon/zoomable-diagram
Zoomable workflow diagrams
2 parents b200923 + 75bea54 commit 8edaf26

File tree

1 file changed

+338
-2
lines changed

1 file changed

+338
-2
lines changed

website/components/MarkdownRenderer.vue

Lines changed: 338 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,207 @@ function renderMermaidDiagrams() {
4545
if (parent) {
4646
try {
4747
const id = `mermaid-${Math.random().toString(36).substr(2, 9)}`;
48-
mermaid.render(id, code).then((value) => (parent.innerHTML = value.svg));
49-
parent.classList.add("not-prose");
48+
mermaid.render(id, code).then((value) => {
49+
// Create container with zoom controls
50+
const container = document.createElement("div");
51+
container.className = "mermaid-zoom-container";
52+
53+
const controls = document.createElement("div");
54+
controls.className = "mermaid-zoom-controls";
55+
56+
// Create buttons
57+
const zoomInBtn = document.createElement("button");
58+
zoomInBtn.textContent = "+";
59+
zoomInBtn.className = "mermaid-zoom-btn";
60+
zoomInBtn.setAttribute("aria-label", "Zoom in");
61+
62+
const zoomOutBtn = document.createElement("button");
63+
zoomOutBtn.textContent = "";
64+
zoomOutBtn.className = "mermaid-zoom-btn";
65+
zoomOutBtn.setAttribute("aria-label", "Zoom out");
66+
67+
const resetBtn = document.createElement("button");
68+
resetBtn.textContent = "";
69+
resetBtn.className = "mermaid-zoom-btn";
70+
resetBtn.setAttribute("aria-label", "Reset zoom");
71+
72+
const fullscreenBtn = document.createElement("button");
73+
fullscreenBtn.textContent = "";
74+
fullscreenBtn.className = "mermaid-zoom-btn";
75+
fullscreenBtn.setAttribute("aria-label", "Toggle fullscreen");
76+
77+
controls.appendChild(zoomInBtn);
78+
controls.appendChild(zoomOutBtn);
79+
controls.appendChild(resetBtn);
80+
controls.appendChild(fullscreenBtn);
81+
82+
const content = document.createElement("div");
83+
content.className = "mermaid-zoom-content";
84+
content.innerHTML = value.svg;
85+
86+
container.appendChild(controls);
87+
container.appendChild(content);
88+
parent.innerHTML = "";
89+
parent.appendChild(container);
90+
parent.classList.add("not-prose");
91+
92+
// Zoom functionality
93+
let scale = 1;
94+
let originX = 0;
95+
let originY = 0;
96+
let isDragging = false;
97+
let startX = 0;
98+
let startY = 0;
99+
100+
const svg = content.querySelector("svg");
101+
if (svg) {
102+
svg.style.cursor = "grab";
103+
svg.style.transformOrigin = "0 0";
104+
105+
function updateTransform() {
106+
svg.style.transform = `translate(${originX}px, ${originY}px) scale(${scale})`;
107+
}
108+
109+
let lastMouseX = content.offsetWidth / 2;
110+
let lastMouseY = content.offsetHeight / 2;
111+
112+
content.addEventListener("mousemove", (e) => {
113+
if (!isDragging) {
114+
const rect = content.getBoundingClientRect();
115+
lastMouseX = e.clientX - rect.left;
116+
lastMouseY = e.clientY - rect.top;
117+
}
118+
});
119+
120+
function zoomToPoint(newScale, mouseX, mouseY) {
121+
const oldScale = scale;
122+
const scaleDiff = newScale / oldScale;
123+
124+
const newOriginX = mouseX - (mouseX - originX) * scaleDiff;
125+
const newOriginY = mouseY - (mouseY - originY) * scaleDiff;
126+
127+
scale = newScale;
128+
originX = newOriginX;
129+
originY = newOriginY;
130+
updateTransform();
131+
}
132+
133+
zoomInBtn.addEventListener("click", () => {
134+
svg.style.transition = "transform 0.2s ease";
135+
const newScale = Math.min(scale * 1.2, 5);
136+
zoomToPoint(newScale, lastMouseX, lastMouseY);
137+
setTimeout(() => (svg.style.transition = "none"), 200);
138+
});
139+
140+
zoomOutBtn.addEventListener("click", () => {
141+
svg.style.transition = "transform 0.2s ease";
142+
const newScale = Math.max(scale / 1.2, 0.1);
143+
zoomToPoint(newScale, lastMouseX, lastMouseY);
144+
setTimeout(() => (svg.style.transition = "none"), 200);
145+
});
146+
147+
resetBtn.addEventListener("click", () => {
148+
svg.style.transition = "transform 0.3s ease";
149+
scale = 1;
150+
originX = 0;
151+
originY = 0;
152+
updateTransform();
153+
setTimeout(() => (svg.style.transition = "none"), 300);
154+
});
155+
156+
fullscreenBtn.addEventListener("click", () => {
157+
if (container.classList.contains("mermaid-fullscreen")) {
158+
container.classList.remove("mermaid-fullscreen");
159+
document.body.style.overflow = "";
160+
} else {
161+
container.classList.add("mermaid-fullscreen");
162+
document.body.style.overflow = "hidden";
163+
}
164+
});
165+
166+
// Mouse wheel zoom
167+
content.addEventListener("wheel", (e) => {
168+
e.preventDefault();
169+
const rect = content.getBoundingClientRect();
170+
const mouseX = e.clientX - rect.left;
171+
const mouseY = e.clientY - rect.top;
172+
173+
const zoomFactor = 1.02;
174+
const newScale =
175+
e.deltaY > 0 ? Math.max(scale / zoomFactor, 0.1) : Math.min(scale * zoomFactor, 5);
176+
177+
zoomToPoint(newScale, mouseX, mouseY);
178+
});
179+
180+
svg.addEventListener("mousedown", (e) => {
181+
isDragging = true;
182+
startX = e.clientX - originX;
183+
startY = e.clientY - originY;
184+
svg.style.cursor = "grabbing";
185+
e.preventDefault();
186+
});
187+
188+
document.addEventListener("mousemove", (e) => {
189+
if (isDragging) {
190+
originX = e.clientX - startX;
191+
originY = e.clientY - startY;
192+
updateTransform();
193+
}
194+
});
195+
196+
document.addEventListener("mouseup", () => {
197+
if (isDragging) {
198+
isDragging = false;
199+
svg.style.cursor = "grab";
200+
}
201+
});
202+
203+
// Touch support
204+
let initialDistance = 0;
205+
let initialScale = 1;
206+
207+
svg.addEventListener("touchstart", (e) => {
208+
if (e.touches.length === 2) {
209+
const touch1 = e.touches[0];
210+
const touch2 = e.touches[1];
211+
initialDistance = Math.hypot(
212+
touch2.clientX - touch1.clientX,
213+
touch2.clientY - touch1.clientY,
214+
);
215+
initialScale = scale;
216+
} else if (e.touches.length === 1) {
217+
isDragging = true;
218+
const touch = e.touches[0];
219+
startX = touch.clientX - originX;
220+
startY = touch.clientY - originY;
221+
}
222+
e.preventDefault();
223+
});
224+
225+
svg.addEventListener("touchmove", (e) => {
226+
if (e.touches.length === 2) {
227+
const touch1 = e.touches[0];
228+
const touch2 = e.touches[1];
229+
const distance = Math.hypot(
230+
touch2.clientX - touch1.clientX,
231+
touch2.clientY - touch1.clientY,
232+
);
233+
scale = Math.max(0.1, Math.min(5, initialScale * (distance / initialDistance)));
234+
updateTransform();
235+
} else if (e.touches.length === 1 && isDragging) {
236+
const touch = e.touches[0];
237+
originX = touch.clientX - startX;
238+
originY = touch.clientY - startY;
239+
updateTransform();
240+
}
241+
e.preventDefault();
242+
});
243+
244+
svg.addEventListener("touchend", () => {
245+
isDragging = false;
246+
});
247+
}
248+
});
50249
} catch (e) {
51250
console.error("Mermaid rendering error:", e);
52251
}
@@ -58,3 +257,140 @@ function renderMermaidDiagrams() {
58257
<template>
59258
<div v-html="renderedHtml"></div>
60259
</template>
260+
261+
<style scoped>
262+
:deep(.mermaid-zoom-container) {
263+
position: relative;
264+
border: 1px solid #e5e7eb;
265+
border-radius: 8px;
266+
background: #fff;
267+
margin: 1rem 0;
268+
overflow: hidden;
269+
}
270+
271+
:deep(.mermaid-zoom-container.mermaid-fullscreen) {
272+
position: fixed;
273+
top: 0;
274+
left: 0;
275+
width: 100vw;
276+
height: 100vh;
277+
z-index: 1000;
278+
border-radius: 0;
279+
margin: 0;
280+
}
281+
282+
:deep(.mermaid-zoom-controls) {
283+
position: absolute;
284+
top: 8px;
285+
right: 8px;
286+
display: flex;
287+
gap: 4px;
288+
z-index: 10;
289+
background: rgba(255, 255, 255, 0.9);
290+
backdrop-filter: blur(4px);
291+
border-radius: 6px;
292+
padding: 4px;
293+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
294+
}
295+
296+
:deep(.mermaid-zoom-btn) {
297+
width: 32px;
298+
height: 32px;
299+
border: 1px solid #d1d5db;
300+
background: #f9fafb;
301+
color: #374151;
302+
border-radius: 4px;
303+
display: flex;
304+
align-items: center;
305+
justify-content: center;
306+
cursor: pointer;
307+
font-size: 16px;
308+
font-weight: bold;
309+
transition: all 0.2s ease;
310+
user-select: none;
311+
}
312+
313+
:deep(.mermaid-zoom-btn:hover) {
314+
background: #e5e7eb;
315+
border-color: #9ca3af;
316+
}
317+
318+
:deep(.mermaid-zoom-btn:active) {
319+
transform: scale(0.95);
320+
}
321+
322+
:deep(.mermaid-zoom-content) {
323+
width: 100%;
324+
height: 400px;
325+
overflow: hidden;
326+
position: relative;
327+
display: flex;
328+
align-items: center;
329+
justify-content: center;
330+
}
331+
332+
:deep(.mermaid-fullscreen .mermaid-zoom-content) {
333+
height: 100vh;
334+
}
335+
336+
:deep(.mermaid-zoom-content svg) {
337+
transform-origin: center center;
338+
max-width: none;
339+
max-height: none;
340+
user-select: none;
341+
}
342+
343+
/* Dark mode support */
344+
:deep(.dark .mermaid-zoom-container) {
345+
border-color: #374151;
346+
background: #1f2937;
347+
}
348+
349+
:deep(.dark .mermaid-zoom-controls) {
350+
background: rgba(31, 41, 55, 0.9);
351+
}
352+
353+
:deep(.dark .mermaid-zoom-btn) {
354+
border-color: #4b5563;
355+
background: #374151;
356+
color: #d1d5db;
357+
}
358+
359+
:deep(.dark .mermaid-zoom-btn:hover) {
360+
background: #4b5563;
361+
border-color: #6b7280;
362+
}
363+
364+
/* Instructions overlay for first time users */
365+
:deep(.mermaid-zoom-container::after) {
366+
content: "💡 Use mouse wheel to zoom, drag to pan, or use controls in top-right";
367+
position: absolute;
368+
bottom: 8px;
369+
left: 8px;
370+
font-size: 12px;
371+
color: #6b7280;
372+
background: rgba(255, 255, 255, 0.9);
373+
backdrop-filter: blur(4px);
374+
padding: 4px 8px;
375+
border-radius: 4px;
376+
opacity: 0;
377+
animation: fade-in-out 4s ease-in-out;
378+
pointer-events: none;
379+
}
380+
381+
:deep(.dark .mermaid-zoom-container::after) {
382+
background: rgba(31, 41, 55, 0.9);
383+
color: #d1d5db;
384+
}
385+
386+
@keyframes fade-in-out {
387+
0%,
388+
100% {
389+
opacity: 0;
390+
}
391+
20%,
392+
80% {
393+
opacity: 1;
394+
}
395+
}
396+
</style>

0 commit comments

Comments
 (0)