mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-12-19 10:54:06 -06:00
feat: Implement touch gestures (pan, pinch-to-zoom) for Mermaid diagrams
This commit is contained in:
parent
c99c8070f2
commit
a7d6e22a5e
@ -11,7 +11,8 @@ class DiagramPanZoom {
|
|||||||
private currentPan: Position = { x: 0, y: 0 }
|
private currentPan: Position = { x: 0, y: 0 }
|
||||||
private scale = 1
|
private scale = 1
|
||||||
private readonly MIN_SCALE = 0.5
|
private readonly MIN_SCALE = 0.5
|
||||||
private readonly MAX_SCALE = 3
|
private readonly MAX_SCALE = 5
|
||||||
|
private lastTouchDistance = 0
|
||||||
|
|
||||||
cleanups: (() => void)[] = []
|
cleanups: (() => void)[] = []
|
||||||
|
|
||||||
@ -31,16 +32,28 @@ class DiagramPanZoom {
|
|||||||
const mouseUpHandler = this.onMouseUp.bind(this)
|
const mouseUpHandler = this.onMouseUp.bind(this)
|
||||||
const resizeHandler = this.resetTransform.bind(this)
|
const resizeHandler = this.resetTransform.bind(this)
|
||||||
|
|
||||||
|
// Touch events
|
||||||
|
const touchStartHandler = this.onTouchStart.bind(this)
|
||||||
|
const touchMoveHandler = this.onTouchMove.bind(this)
|
||||||
|
const touchEndHandler = this.onTouchEnd.bind(this)
|
||||||
|
|
||||||
this.container.addEventListener("mousedown", mouseDownHandler)
|
this.container.addEventListener("mousedown", mouseDownHandler)
|
||||||
document.addEventListener("mousemove", mouseMoveHandler)
|
document.addEventListener("mousemove", mouseMoveHandler)
|
||||||
document.addEventListener("mouseup", mouseUpHandler)
|
document.addEventListener("mouseup", mouseUpHandler)
|
||||||
window.addEventListener("resize", resizeHandler)
|
window.addEventListener("resize", resizeHandler)
|
||||||
|
|
||||||
|
this.container.addEventListener("touchstart", touchStartHandler, { passive: false })
|
||||||
|
this.container.addEventListener("touchmove", touchMoveHandler, { passive: false })
|
||||||
|
this.container.addEventListener("touchend", touchEndHandler)
|
||||||
|
|
||||||
this.cleanups.push(
|
this.cleanups.push(
|
||||||
() => this.container.removeEventListener("mousedown", mouseDownHandler),
|
() => this.container.removeEventListener("mousedown", mouseDownHandler),
|
||||||
() => document.removeEventListener("mousemove", mouseMoveHandler),
|
() => document.removeEventListener("mousemove", mouseMoveHandler),
|
||||||
() => document.removeEventListener("mouseup", mouseUpHandler),
|
() => document.removeEventListener("mouseup", mouseUpHandler),
|
||||||
() => window.removeEventListener("resize", resizeHandler),
|
() => window.removeEventListener("resize", resizeHandler),
|
||||||
|
() => this.container.removeEventListener("touchstart", touchStartHandler),
|
||||||
|
() => this.container.removeEventListener("touchmove", touchMoveHandler),
|
||||||
|
() => this.container.removeEventListener("touchend", touchEndHandler),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,8 +68,8 @@ class DiagramPanZoom {
|
|||||||
controls.className = "mermaid-controls"
|
controls.className = "mermaid-controls"
|
||||||
|
|
||||||
// Zoom controls
|
// Zoom controls
|
||||||
const zoomIn = this.createButton("+", () => this.zoom(0.1))
|
const zoomIn = this.createButton("+", () => this.zoom(0.25))
|
||||||
const zoomOut = this.createButton("-", () => this.zoom(-0.1))
|
const zoomOut = this.createButton("-", () => this.zoom(-0.25))
|
||||||
const resetBtn = this.createButton("Reset", () => this.resetTransform())
|
const resetBtn = this.createButton("Reset", () => this.resetTransform())
|
||||||
|
|
||||||
controls.appendChild(zoomOut)
|
controls.appendChild(zoomOut)
|
||||||
@ -99,17 +112,87 @@ class DiagramPanZoom {
|
|||||||
this.container.style.cursor = "grab"
|
this.container.style.cursor = "grab"
|
||||||
}
|
}
|
||||||
|
|
||||||
private zoom(delta: number) {
|
private getTouchDistance(touches: TouchList): number {
|
||||||
|
const touch1 = touches[0]
|
||||||
|
const touch2 = touches[1]
|
||||||
|
const dx = touch1.clientX - touch2.clientX
|
||||||
|
const dy = touch1.clientY - touch2.clientY
|
||||||
|
return Math.sqrt(dx * dx + dy * dy)
|
||||||
|
}
|
||||||
|
|
||||||
|
private onTouchStart(e: TouchEvent) {
|
||||||
|
if (e.touches.length === 1) {
|
||||||
|
// Single touch - start panning
|
||||||
|
this.isDragging = true
|
||||||
|
this.startPan = {
|
||||||
|
x: e.touches[0].clientX - this.currentPan.x,
|
||||||
|
y: e.touches[0].clientY - this.currentPan.y,
|
||||||
|
}
|
||||||
|
} else if (e.touches.length === 2) {
|
||||||
|
// Two touches - start zooming
|
||||||
|
this.isDragging = false
|
||||||
|
this.lastTouchDistance = this.getTouchDistance(e.touches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onTouchMove(e: TouchEvent) {
|
||||||
|
if (e.touches.length === 1 && this.isDragging) {
|
||||||
|
// Pan
|
||||||
|
e.preventDefault()
|
||||||
|
this.currentPan = {
|
||||||
|
x: e.touches[0].clientX - this.startPan.x,
|
||||||
|
y: e.touches[0].clientY - this.startPan.y,
|
||||||
|
}
|
||||||
|
this.updateTransform()
|
||||||
|
} else if (e.touches.length === 2) {
|
||||||
|
// Zoom
|
||||||
|
e.preventDefault()
|
||||||
|
const currentDistance = this.getTouchDistance(e.touches)
|
||||||
|
const delta = (currentDistance - this.lastTouchDistance) * 0.02
|
||||||
|
|
||||||
|
const center = {
|
||||||
|
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
|
||||||
|
y: (e.touches[0].clientY + e.touches[1].clientY) / 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.zoom(delta, center)
|
||||||
|
this.lastTouchDistance = currentDistance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onTouchEnd(e: TouchEvent) {
|
||||||
|
if (e.touches.length === 0) {
|
||||||
|
this.isDragging = false
|
||||||
|
} else if (e.touches.length === 1) {
|
||||||
|
// Switch to panning if one finger remains
|
||||||
|
this.isDragging = true
|
||||||
|
this.startPan = {
|
||||||
|
x: e.touches[0].clientX - this.currentPan.x,
|
||||||
|
y: e.touches[0].clientY - this.currentPan.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private zoom(delta: number, center?: Position) {
|
||||||
const newScale = Math.min(Math.max(this.scale + delta, this.MIN_SCALE), this.MAX_SCALE)
|
const newScale = Math.min(Math.max(this.scale + delta, this.MIN_SCALE), this.MAX_SCALE)
|
||||||
|
|
||||||
// Zoom around center
|
// Calculate zoom center relative to container
|
||||||
const rect = this.content.getBoundingClientRect()
|
const rect = this.container.getBoundingClientRect()
|
||||||
const centerX = rect.width / 2
|
let pX: number
|
||||||
const centerY = rect.height / 2
|
let pY: number
|
||||||
|
|
||||||
const scaleDiff = newScale - this.scale
|
if (center) {
|
||||||
this.currentPan.x -= centerX * scaleDiff
|
pX = center.x - rect.left
|
||||||
this.currentPan.y -= centerY * scaleDiff
|
pY = center.y - rect.top
|
||||||
|
} else {
|
||||||
|
// Default to center of container
|
||||||
|
pX = rect.width / 2
|
||||||
|
pY = rect.height / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
const scaleRatio = newScale / this.scale
|
||||||
|
this.currentPan.x = pX - (pX - this.currentPan.x) * scaleRatio
|
||||||
|
this.currentPan.y = pY - (pY - this.currentPan.y) * scaleRatio
|
||||||
|
|
||||||
this.scale = newScale
|
this.scale = newScale
|
||||||
this.updateTransform()
|
this.updateTransform()
|
||||||
@ -120,13 +203,32 @@ class DiagramPanZoom {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resetTransform() {
|
private resetTransform() {
|
||||||
this.scale = 1
|
this.content.style.transition = "none" // Disable transition for instant reset
|
||||||
const svg = this.content.querySelector("svg")!
|
this.content.style.transform = "" // Reset transform to get accurate dimensions
|
||||||
|
|
||||||
|
const containerRect = this.container.getBoundingClientRect()
|
||||||
|
const contentRect = this.content.getBoundingClientRect()
|
||||||
|
|
||||||
|
// Calculate scale to fit
|
||||||
|
const widthRatio = containerRect.width / contentRect.width
|
||||||
|
const heightRatio = containerRect.height / contentRect.height
|
||||||
|
const fitScale = Math.min(widthRatio, heightRatio) * 0.9 // 90% fit
|
||||||
|
|
||||||
|
// Clamp scale
|
||||||
|
this.scale = Math.min(Math.max(fitScale, this.MIN_SCALE), this.MAX_SCALE)
|
||||||
|
|
||||||
|
// Center based on new scale
|
||||||
this.currentPan = {
|
this.currentPan = {
|
||||||
x: svg.getBoundingClientRect().width / 2,
|
x: (containerRect.width - contentRect.width * this.scale) / 2,
|
||||||
y: svg.getBoundingClientRect().height / 2,
|
y: (containerRect.height - contentRect.height * this.scale) / 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateTransform()
|
this.updateTransform()
|
||||||
|
|
||||||
|
// Re-enable transition after a frame
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.content.style.transition = ""
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: 0.2s;
|
transition: 0.2s;
|
||||||
|
|
||||||
& > svg {
|
&>svg {
|
||||||
fill: var(--light);
|
fill: var(--light);
|
||||||
filter: contrast(0.3);
|
filter: contrast(0.3);
|
||||||
}
|
}
|
||||||
@ -29,7 +29,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
&:hover > .expand-button {
|
&:hover>.expand-button {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transition: 0.2s;
|
transition: 0.2s;
|
||||||
}
|
}
|
||||||
@ -52,7 +52,7 @@ pre {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
& > #mermaid-space {
|
&>#mermaid-space {
|
||||||
border: 1px solid var(--lightgray);
|
border: 1px solid var(--lightgray);
|
||||||
background-color: var(--light);
|
background-color: var(--light);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@ -63,15 +63,18 @@ pre {
|
|||||||
height: 80vh;
|
height: 80vh;
|
||||||
width: 80vw;
|
width: 80vw;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
touch-action: none;
|
||||||
|
|
||||||
& > .mermaid-content {
|
&>.mermaid-content {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
transition: transform 0.1s ease;
|
transition: transform 0.1s ease;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
min-height: 200px;
|
width: fit-content;
|
||||||
min-width: 200px;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -84,7 +87,7 @@ pre {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
& > .mermaid-controls {
|
&>.mermaid-controls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user