Skip to content

Commit effabb8

Browse files
heavygeecursoragent
andcommitted
fix(web): keep mermaid lightbox content below the toolbar
Operator screenshot showed the diagram top (e.g. pie 'Pets' title) clipped behind the toolbar bar. Two causes: 1. getScreenFitSize used the full viewport height, so the fit scale sized the diagram to fill an area the toolbar overlapped. 2. The viewport (drag/zoom area) was inset-0; content centered on the full viewport center, not the visible region's center, pushing the top behind the toolbar. Measure the toolbar with a ResizeObserver, subtract its height from the fit calculation (clamped at zero), and start the viewport region below the toolbar (top: toolbarHeight). Fit scale recomputes whenever toolbar height changes. Adds Vitest coverage for getScreenFitSize reserved-top math. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent c797d09 commit effabb8

2 files changed

Lines changed: 87 additions & 9 deletions

File tree

web/src/components/ZoomableLightbox.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest'
2-
import { measureContentSize, measureSvgIntrinsicSize } from './ZoomableLightbox'
2+
import { getScreenFitSize, measureContentSize, measureSvgIntrinsicSize } from './ZoomableLightbox'
33

44
type Rect = { width: number; height: number }
55

@@ -75,6 +75,60 @@ describe('measureSvgIntrinsicSize', () => {
7575
})
7676
})
7777

78+
describe('getScreenFitSize', () => {
79+
const originalVisualViewport = Object.getOwnPropertyDescriptor(window, 'visualViewport')
80+
const originalInnerWidth = window.innerWidth
81+
const originalInnerHeight = window.innerHeight
82+
83+
function setViewport(width: number, height: number) {
84+
Object.defineProperty(window, 'visualViewport', {
85+
configurable: true,
86+
value: { width, height },
87+
})
88+
}
89+
90+
function clearViewport() {
91+
Object.defineProperty(window, 'visualViewport', { configurable: true, value: null })
92+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth })
93+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight })
94+
}
95+
96+
function restore() {
97+
if (originalVisualViewport) {
98+
Object.defineProperty(window, 'visualViewport', originalVisualViewport)
99+
}
100+
}
101+
102+
it('subtracts the reserved top region (toolbar) from height', () => {
103+
setViewport(1000, 800)
104+
try {
105+
expect(getScreenFitSize(40)).toEqual({ width: 1000, height: 760 })
106+
} finally {
107+
restore()
108+
}
109+
})
110+
111+
it('clamps reserved height at zero (no negative regions)', () => {
112+
setViewport(800, 100)
113+
try {
114+
expect(getScreenFitSize(200)).toEqual({ width: 800, height: 0 })
115+
} finally {
116+
restore()
117+
}
118+
})
119+
120+
it('falls back to window inner size when visualViewport is unavailable', () => {
121+
clearViewport()
122+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 })
123+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 })
124+
try {
125+
expect(getScreenFitSize(60)).toEqual({ width: 1280, height: 840 })
126+
} finally {
127+
restore()
128+
}
129+
})
130+
})
131+
78132
describe('measureContentSize', () => {
79133
it('prefers img.naturalSize over its bounding rect', () => {
80134
const content = makeContent({

web/src/components/ZoomableLightbox.tsx

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ const BACKDROP_CLICK_MAX_MOVEMENT = 4
1010
/** Edge margin when fitting to the device screen (not the inner panel only). */
1111
const SCREEN_FIT_PADDING_PX = 12
1212

13-
function getScreenFitSize(): { width: number; height: number } {
13+
export function getScreenFitSize(reservedTopPx = 0): { width: number; height: number } {
1414
const viewport = window.visualViewport
15-
if (viewport) {
16-
return { width: viewport.width, height: viewport.height }
17-
}
18-
return { width: window.innerWidth, height: window.innerHeight }
15+
const width = viewport ? viewport.width : window.innerWidth
16+
const fullHeight = viewport ? viewport.height : window.innerHeight
17+
const reserved = Math.max(0, reservedTopPx)
18+
return { width, height: Math.max(0, fullHeight - reserved) }
1919
}
2020

2121
type Point = { x: number; y: number }
@@ -128,11 +128,13 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) {
128128
} = props
129129
const [scale, setScale] = useState(1)
130130
const [offset, setOffset] = useState({ x: 0, y: 0 })
131+
const [toolbarHeight, setToolbarHeight] = useState(0)
131132
const scaleRef = useRef(scale)
132133
const offsetRef = useRef(offset)
133134
const baseScaleRef = useRef(1)
134135
const viewportRef = useRef<HTMLDivElement>(null)
135136
const contentRef = useRef<HTMLDivElement>(null)
137+
const toolbarRef = useRef<HTMLDivElement>(null)
136138
const activePointersRef = useRef(new Map<number, Point>())
137139
const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>(null)
138140
const pinchRef = useRef<{ startDistance: number; startScale: number; startCenter: Point; origin: Point } | null>(null)
@@ -165,7 +167,7 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) {
165167
const contentSize = fitContentSize ?? measureContentSize(content, scaleRef.current)
166168
if (!contentSize) return
167169

168-
const screen = getScreenFitSize()
170+
const screen = getScreenFitSize(toolbarHeight)
169171
const pad = SCREEN_FIT_PADDING_PX * 2
170172
const fitWidth = (screen.width - pad) / contentSize.width
171173
const fitHeight = (screen.height - pad) / contentSize.height
@@ -174,7 +176,7 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) {
174176
baseScaleRef.current = fitScale
175177
updateScale(fitScale)
176178
updateOffset({ x: 0, y: 0 })
177-
}, [fitContentSize, fitOnOpen, updateOffset, updateScale])
179+
}, [fitContentSize, fitOnOpen, toolbarHeight, updateOffset, updateScale])
178180

179181
const resetView = useCallback(() => {
180182
updateScale(baseScaleRef.current)
@@ -346,6 +348,26 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) {
346348
}
347349
}, [fitContentKey, fitContentSize, fitOnOpen, open, applyFitScale])
348350

351+
useLayoutEffect(() => {
352+
if (!open) return undefined
353+
const toolbar = toolbarRef.current
354+
if (!toolbar) return undefined
355+
356+
const apply = () => {
357+
const next = toolbar.getBoundingClientRect().height
358+
setToolbarHeight((current) => (Math.abs(current - next) < 0.5 ? current : next))
359+
}
360+
361+
apply()
362+
const resize = new ResizeObserver(apply)
363+
resize.observe(toolbar)
364+
window.addEventListener('resize', apply)
365+
return () => {
366+
resize.disconnect()
367+
window.removeEventListener('resize', apply)
368+
}
369+
}, [open])
370+
349371
useEffect(() => {
350372
if (!open) return
351373

@@ -385,7 +407,8 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) {
385407
>
386408
<div
387409
ref={viewportRef}
388-
className="absolute inset-0 cursor-grab touch-none overflow-hidden active:cursor-grabbing"
410+
className="absolute inset-x-0 bottom-0 cursor-grab touch-none overflow-hidden active:cursor-grabbing"
411+
style={{ top: `${toolbarHeight}px` }}
389412
onWheel={handleWheel}
390413
onPointerDown={handlePointerDown}
391414
onPointerMove={handlePointerMove}
@@ -405,6 +428,7 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) {
405428
</div>
406429
</div>
407430
<div
431+
ref={toolbarRef}
408432
className="pointer-events-none absolute inset-x-0 top-0 z-10 pt-[env(safe-area-inset-top,0px)]"
409433
onPointerDown={(event) => event.stopPropagation()}
410434
>

0 commit comments

Comments
 (0)