@@ -27,6 +27,7 @@ import {
2727 type ImageAnnotation ,
2828 type NoteAnnotation ,
2929 type FreetextAnnotation ,
30+ cssColorToRgb ,
3031 serializeDiff ,
3132 deserializeDiff ,
3233 mergeAnnotations ,
@@ -1933,13 +1934,20 @@ function renderAnnotation(
19331934 viewport : { width : number ; height : number ; scale : number } ,
19341935) : HTMLElement [ ] {
19351936 switch ( def . type ) {
1936- case "highlight" :
1937+ case "highlight" : {
1938+ // Force translucency: def.color is an opaque hex (e.g. "#ffff00"), which
1939+ // would override the rgba()/mix-blend-mode in CSS and hide the text.
1940+ const rgb = def . color ? cssColorToRgb ( def . color ) : null ;
1941+ const bg = rgb
1942+ ? `rgba(${ Math . round ( rgb . r * 255 ) } , ${ Math . round ( rgb . g * 255 ) } , ${ Math . round ( rgb . b * 255 ) } , 0.35)`
1943+ : undefined ;
19371944 return renderRectsAnnotation (
19381945 def . rects ,
19391946 "annotation-highlight" ,
19401947 viewport ,
1941- def . color ? { background : def . color } : { } ,
1948+ bg ? { background : bg } : { } ,
19421949 ) ;
1950+ }
19431951 case "underline" :
19441952 return renderRectsAnnotation (
19451953 def . rects ,
@@ -3759,19 +3767,31 @@ document.addEventListener("selectionchange", () => {
37593767let pinchStartScale = 1.0 ;
37603768/** What we'd commit to if the gesture ended right now. */
37613769let previewScale = 1.0 ;
3770+ /** Unclamped target — used to detect "pinched out past fit" even when
3771+ * previewScale is pinned at ZOOM_MIN. */
3772+ let previewScaleRaw = 1.0 ;
37623773/** Debounce timer — wheel events have no end event, so we wait for quiet. */
37633774let pinchSettleTimer : ReturnType < typeof setTimeout > | null = null ;
3775+ /** computeFitScale() snapshot at gesture start (async — may be null briefly). */
3776+ let fitScaleAtPinchStart : number | null = null ;
3777+ /** Guards against firing toggleFullscreen() once per wheel event during a
3778+ * single inline pinch-in gesture. */
3779+ let modeTransitionInFlight = false ;
37643780
37653781function beginPinch ( ) {
37663782 pinchStartScale = scale ;
37673783 previewScale = scale ;
3784+ previewScaleRaw = scale ;
3785+ fitScaleAtPinchStart = null ;
3786+ void computeFitScale ( ) . then ( ( s ) => ( fitScaleAtPinchStart = s ) ) ;
37683787 // transform-origin matches the flex layout's anchor (justify-content:
37693788 // center, align-items: flex-start) so the preview and the committed
37703789 // canvas grow from the same point — otherwise the page jumps on release.
37713790 pageWrapperEl . style . transformOrigin = "50% 0" ;
37723791}
37733792
37743793function updatePinch ( nextScale : number ) {
3794+ previewScaleRaw = nextScale ;
37753795 previewScale = Math . min ( ZOOM_MAX , Math . max ( ZOOM_MIN , nextScale ) ) ;
37763796 // Transform is RELATIVE to the rendered canvas (which sits at
37773797 // pinchStartScale), so a previewScale equal to pinchStartScale → ratio 1.
@@ -3780,6 +3800,23 @@ function updatePinch(nextScale: number) {
37803800}
37813801
37823802function commitPinch ( ) {
3803+ // Pinching out past fit while already at (or below) fit → user wants to
3804+ // leave fullscreen, not zoom further out. 0.9× threshold so a slight
3805+ // overshoot doesn't eject them.
3806+ if (
3807+ currentDisplayMode === "fullscreen" &&
3808+ fitScaleAtPinchStart !== null &&
3809+ pinchStartScale <= fitScaleAtPinchStart + 0.01 &&
3810+ previewScaleRaw < fitScaleAtPinchStart * 0.9
3811+ ) {
3812+ pageWrapperEl . style . transform = "" ;
3813+ userHasZoomed = false ; // let refitScale() size the inline view
3814+ modeTransitionInFlight = true ;
3815+ void toggleFullscreen ( ) . finally ( ( ) => {
3816+ setTimeout ( ( ) => ( modeTransitionInFlight = false ) , 250 ) ;
3817+ } ) ;
3818+ return ;
3819+ }
37833820 if ( Math . abs ( previewScale - scale ) < 0.01 ) {
37843821 // Dead-zone — no re-render. Clear here since renderPage won't run.
37853822 pageWrapperEl . style . transform = "" ;
@@ -3804,8 +3841,23 @@ canvasContainerEl.addEventListener(
38043841 // Trackpad pinch arrives as wheel with ctrlKey set (Chrome/FF/Edge on
38053842 // macOS+Windows, Safari on macOS). MUST check before the deltaX/deltaY
38063843 // comparison below — pinch deltas come through on deltaY.
3807- if ( e . ctrlKey && currentDisplayMode === "fullscreen" ) {
3844+ if ( e . ctrlKey ) {
38083845 e . preventDefault ( ) ;
3846+ if ( currentDisplayMode !== "fullscreen" ) {
3847+ // Inline: pinch-in (deltaY<0) is a request to go fullscreen.
3848+ // Pinch-out is ignored — nothing smaller than inline.
3849+ if ( e . deltaY < 0 && ! modeTransitionInFlight ) {
3850+ modeTransitionInFlight = true ;
3851+ void toggleFullscreen ( ) . finally ( ( ) => {
3852+ // Hold the latch through the settle window so the tail of the
3853+ // gesture doesn't immediately start zooming the new fullscreen
3854+ // view (or, worse, re-toggle).
3855+ setTimeout ( ( ) => ( modeTransitionInFlight = false ) , 250 ) ;
3856+ } ) ;
3857+ }
3858+ return ;
3859+ }
3860+ if ( modeTransitionInFlight ) return ; // swallow gesture tail post-toggle
38093861 if ( pinchSettleTimer === null ) beginPinch ( ) ;
38103862 // exp(-deltaY * k) makes equal-magnitude in/out deltas inverse —
38113863 // pinch out then back lands where you started. Clamp per event so a
@@ -3858,7 +3910,7 @@ canvasContainerEl.addEventListener(
38583910 "touchstart" ,
38593911 ( event ) => {
38603912 const e = event as TouchEvent ;
3861- if ( e . touches . length !== 2 || currentDisplayMode !== "fullscreen" ) return ;
3913+ if ( e . touches . length !== 2 ) return ;
38623914 // No preventDefault here — keep iOS Safari happy. We block native
38633915 // pinch-zoom via touch-action CSS + preventDefault on touchmove.
38643916 touchStartDist = touchDist ( e . touches ) ;
@@ -3873,7 +3925,21 @@ canvasContainerEl.addEventListener(
38733925 const e = event as TouchEvent ;
38743926 if ( e . touches . length !== 2 || touchStartDist === 0 ) return ;
38753927 e . preventDefault ( ) ; // stop the browser zooming the whole viewport
3876- updatePinch ( pinchStartScale * ( touchDist ( e . touches ) / touchStartDist ) ) ;
3928+ const ratio = touchDist ( e . touches ) / touchStartDist ;
3929+ if ( currentDisplayMode !== "fullscreen" ) {
3930+ // Inline: a clear pinch-in means "go fullscreen". 1.15× threshold
3931+ // avoids triggering on jittery two-finger taps/scrolls.
3932+ if ( ratio > 1.15 && ! modeTransitionInFlight ) {
3933+ modeTransitionInFlight = true ;
3934+ touchStartDist = 0 ; // end this gesture; fullscreen will refit
3935+ pageWrapperEl . style . transform = "" ;
3936+ void toggleFullscreen ( ) . finally ( ( ) => {
3937+ setTimeout ( ( ) => ( modeTransitionInFlight = false ) , 250 ) ;
3938+ } ) ;
3939+ }
3940+ return ;
3941+ }
3942+ updatePinch ( pinchStartScale * ratio ) ;
38773943 } ,
38783944 { passive : false } ,
38793945) ;
@@ -3884,6 +3950,11 @@ canvasContainerEl.addEventListener("touchend", (event) => {
38843950 // REMAINING set — lifting one of two leaves length 1.
38853951 if ( touchStartDist === 0 || e . touches . length >= 2 ) return ;
38863952 touchStartDist = 0 ;
3953+ if ( currentDisplayMode !== "fullscreen" ) {
3954+ // Inline pinch that didn't cross the threshold — discard preview.
3955+ pageWrapperEl . style . transform = "" ;
3956+ return ;
3957+ }
38873958 commitPinch ( ) ;
38883959} ) ;
38893960
0 commit comments