@@ -30,6 +30,9 @@ const EDGE_GAP = 8;
3030/** Matches previous `right-4` / `left-4` panel inset. */
3131const PANEL_EDGE = 16 ;
3232
33+ /** Fixed panel width in px — must match the `w-[380px]` class on the panel div. */
34+ const PANEL_WIDTH = 380 ;
35+
3336type DragSurface = "icon" | "panel" ;
3437
3538// ── Helpers ─────────────────────────────────────────────────────────────────
@@ -57,31 +60,18 @@ function magneticSnapX(x: number): number {
5760 return center < window . innerWidth / 2 ? snapLeftX ( ) : snapRightX ( ) ;
5861}
5962
60- function isPanelDragBlockedTarget ( node : EventTarget | null ) : boolean {
63+ /** Panel drag is only allowed from within the designated drag zone ([data-loop-panel-drag]),
64+ * and never from interactive elements inside it. */
65+ function isPanelDragAllowedStart ( node : EventTarget | null ) : boolean {
6166 if ( ! ( node instanceof Element ) ) return false ;
62- return (
67+ if (
6368 node . closest (
6469 "button, a, input, textarea, select, [contenteditable='true'], [role='button'], [role='tab']" ,
6570 ) !== null
66- ) ;
67- }
68-
69- function isInsideLoopScroll ( node : EventTarget | null ) : boolean {
70- if ( ! ( node instanceof Element ) ) return false ;
71- return node . closest ( "[data-loop-scroll]" ) !== null ;
72- }
73-
74- /** Panel drag: never from scroll content; always from drag chrome or non-scroll areas. */
75- function isPanelDragAllowedStart ( node : EventTarget | null ) : boolean {
76- if ( ! ( node instanceof Element ) ) return false ;
77- if ( isPanelDragBlockedTarget ( node ) ) return false ;
78- if (
79- isInsideLoopScroll ( node ) &&
80- node . closest ( "[data-loop-panel-drag]" ) === null
8171 ) {
8272 return false ;
8373 }
84- return true ;
74+ return node . closest ( "[data-loop-panel-drag]" ) !== null ;
8575}
8676
8777// ── Component ────────────────────────────────────────────────────────────────
@@ -99,6 +89,12 @@ export default function FloatingPanel({
9989
10090 const [ isDragging , setIsDragging ] = useState ( false ) ;
10191
92+ /**
93+ * During a panel-header drag, holds the panel's live `left` px position.
94+ * `null` means the panel is not being dragged.
95+ */
96+ const [ panelDragLeft , setPanelDragLeft ] = useState < number | null > ( null ) ;
97+
10298 const panelRootRef = useRef < HTMLDivElement > ( null ) ;
10399
104100 /** Shared pointer-drag state (icon and panel use the same move / end logic). */
@@ -116,7 +112,10 @@ export default function FloatingPanel({
116112 const dockRight = iconPos . x + ICON_W / 2 >= window . innerWidth / 2 ;
117113
118114 useEffect ( ( ) => {
119- const handler = ( ) => setIsDismissed ( false ) ;
115+ const handler = ( ) => {
116+ setIsDismissed ( false ) ;
117+ setIsOpen ( true ) ;
118+ } ;
120119 panelEvents . addEventListener ( "show" , handler ) ;
121120 return ( ) => panelEvents . removeEventListener ( "show" , handler ) ;
122121 } , [ ] ) ;
@@ -134,20 +133,22 @@ export default function FloatingPanel({
134133 surface : DragSurface ,
135134 e : React . PointerEvent ,
136135 captureTarget : HTMLElement ,
136+ startX : number ,
137+ startY : number ,
137138 ) => {
138139 dragRef . current = {
139140 surface,
140141 pointerId : e . pointerId ,
141142 startClientX : e . clientX ,
142143 startClientY : e . clientY ,
143- startX : iconPos . x ,
144- startY : iconPos . y ,
144+ startX,
145+ startY,
145146 moved : false ,
146147 } ;
147148 setIsDragging ( true ) ;
148149 captureTarget . setPointerCapture ( e . pointerId ) ;
149150 } ,
150- [ iconPos . x , iconPos . y ] ,
151+ [ ] ,
151152 ) ;
152153
153154 const handlePointerMove = useCallback ( ( e : React . PointerEvent ) => {
@@ -156,42 +157,75 @@ export default function FloatingPanel({
156157 const dx = e . clientX - d . startClientX ;
157158 const dy = e . clientY - d . startClientY ;
158159 if ( Math . abs ( dx ) > 4 || Math . abs ( dy ) > 4 ) d . moved = true ;
159- setIconPos ( clampIconPos ( d . startX + dx , d . startY + dy ) ) ;
160- } , [ ] ) ;
161160
162- const endDrag = useCallback ( ( e : React . PointerEvent ) => {
163- if ( ! dragRef . current || e . pointerId !== dragRef . current . pointerId ) return ;
164- const { surface, moved } = dragRef . current ;
165- dragRef . current = null ;
166- setIsDragging ( false ) ;
167-
168- if ( surface === "icon" && ! moved ) {
169- setIsOpen ( true ) ;
170- return ;
161+ if ( d . surface === "icon" ) {
162+ setIconPos ( clampIconPos ( d . startX + dx , d . startY + dy ) ) ;
163+ } else {
164+ // Panel drag: move the panel left/right only; iconPos stays stable so
165+ // dockRight doesn't flicker mid-drag.
166+ const raw = d . startX + dx ;
167+ const clamped = Math . max (
168+ 0 ,
169+ Math . min ( window . innerWidth - PANEL_WIDTH , raw ) ,
170+ ) ;
171+ setPanelDragLeft ( clamped ) ;
171172 }
172-
173- setIconPos ( ( prev ) => clampIconPos ( magneticSnapX ( prev . x ) , prev . y ) ) ;
174173 } , [ ] ) ;
175174
176- const handleLostPointerCapture = useCallback ( ( e : React . PointerEvent ) => {
177- if ( ! dragRef . current || e . pointerId !== dragRef . current . pointerId ) return ;
178- const { surface, moved } = dragRef . current ;
175+ /** Shared finalisation for both pointer-up and lost-capture events. */
176+ const finishDrag = useCallback ( ( pointerId : number , finalClientX : number ) => {
177+ if ( ! dragRef . current || pointerId !== dragRef . current . pointerId ) return ;
178+ const { surface, moved, startX, startClientX } = dragRef . current ;
179179 dragRef . current = null ;
180180 setIsDragging ( false ) ;
181- if ( surface === "icon" && ! moved ) {
182- setIsOpen ( true ) ;
183- return ;
181+
182+ if ( surface === "icon" ) {
183+ if ( ! moved ) {
184+ setIsOpen ( true ) ;
185+ return ;
186+ }
187+ setIconPos ( ( prev ) => clampIconPos ( magneticSnapX ( prev . x ) , prev . y ) ) ;
188+ } else {
189+ // Panel drag: snap to whichever side the panel center is closer to.
190+ setPanelDragLeft ( null ) ;
191+ const finalLeft = startX + ( finalClientX - startClientX ) ;
192+ const panelCenter = finalLeft + PANEL_WIDTH / 2 ;
193+ const snapRight = panelCenter > window . innerWidth / 2 ;
194+ setIconPos ( ( prev ) => ( {
195+ x : snapRight ? snapRightX ( ) : snapLeftX ( ) ,
196+ y : prev . y ,
197+ } ) ) ;
184198 }
185- setIconPos ( ( prev ) => clampIconPos ( magneticSnapX ( prev . x ) , prev . y ) ) ;
186199 } , [ ] ) ;
187200
201+ const endDrag = useCallback (
202+ ( e : React . PointerEvent ) => {
203+ finishDrag ( e . pointerId , e . clientX ) ;
204+ } ,
205+ [ finishDrag ] ,
206+ ) ;
207+
208+ const handleLostPointerCapture = useCallback (
209+ ( e : React . PointerEvent ) => {
210+ finishDrag ( e . pointerId , e . clientX ) ;
211+ } ,
212+ [ finishDrag ] ,
213+ ) ;
214+
188215 const handleIconPointerDown = useCallback (
189216 ( e : React . PointerEvent ) => {
190- if ( ( e . target as HTMLElement ) . closest ( "[data-dismiss-btn]" ) ) return ;
217+ if ( e . target instanceof Element && e . target . closest ( "[data-dismiss-btn]" ) )
218+ return ;
191219 if ( e . button !== 0 ) return ;
192- beginDrag ( "icon" , e , e . currentTarget as HTMLElement ) ;
220+ beginDrag (
221+ "icon" ,
222+ e ,
223+ e . currentTarget as HTMLElement ,
224+ iconPos . x ,
225+ iconPos . y ,
226+ ) ;
193227 } ,
194- [ beginDrag ] ,
228+ [ beginDrag , iconPos . x , iconPos . y ] ,
195229 ) ;
196230
197231 const handlePanelPointerDown = useCallback (
@@ -201,9 +235,13 @@ export default function FloatingPanel({
201235 if ( ! isPanelDragAllowedStart ( e . target ) ) return ;
202236 const el = panelRootRef . current ;
203237 if ( el === null ) return ;
204- beginDrag ( "panel" , e , el ) ;
238+ const startLeft = dockRight
239+ ? window . innerWidth - PANEL_EDGE - PANEL_WIDTH
240+ : PANEL_EDGE ;
241+ setPanelDragLeft ( startLeft ) ;
242+ beginDrag ( "panel" , e , el , startLeft , 0 ) ;
205243 } ,
206- [ isOpen , beginDrag ] ,
244+ [ isOpen , beginDrag , dockRight ] ,
207245 ) ;
208246
209247 // Panel is always full viewport height; launcher Y is independent.
@@ -245,7 +283,10 @@ export default function FloatingPanel({
245283 onPointerCancel = { endDrag }
246284 onLostPointerCapture = { handleLostPointerCapture }
247285 onKeyDown = { ( e ) => {
248- if ( e . key === "Enter" || e . key === " " ) setIsOpen ( true ) ;
286+ if ( e . key === "Enter" || e . key === " " ) {
287+ if ( e . key === " " ) e . preventDefault ( ) ;
288+ setIsOpen ( true ) ;
289+ }
249290 } }
250291 >
251292 { dockRight ? (
@@ -270,10 +311,13 @@ export default function FloatingPanel({
270311 } }
271312 onPointerDown = { ( e ) => e . stopPropagation ( ) }
272313 className = { [
273- "absolute top-0 left-[3px] -translate-y-1/2" ,
314+ // Flip to the outer top corner so the X is always on the viewport-edge side.
315+ "absolute top-0 -translate-y-1/2" ,
316+ dockRight ? "right-[3px]" : "left-[3px]" ,
274317 "flex h-[18px] w-[18px] items-center justify-center" ,
275318 "rounded-full bg-white shadow-[0_1px_4px_rgba(0,0,0,0.22)]" ,
276- "opacity-0 transition-opacity duration-150 group-hover:opacity-100" ,
319+ "opacity-0 transition-opacity duration-150" ,
320+ "group-hover:opacity-100 focus-visible:opacity-100" ,
277321 "text-[var(--color-brand)]" ,
278322 ] . join ( " " ) }
279323 aria-label = "Dismiss Cornell Loop"
@@ -294,16 +338,34 @@ export default function FloatingPanel({
294338 ref = { panelRootRef }
295339 className = { [
296340 "fixed z-[9998] w-[380px] overflow-hidden" ,
297- "transition-transform duration-300 ease-in-out" ,
341+ // Suppress transition while the user is actively dragging so the
342+ // panel tracks the cursor without lag; re-enable for slide-in/out.
343+ panelDragLeft === null
344+ ? "transition-transform duration-300 ease-in-out"
345+ : "" ,
346+ // Drives the grabbing cursor override in content.css.
347+ panelDragLeft !== null ? "loop-panel-dragging" : "" ,
298348 ] . join ( " " ) }
299- style = { {
300- top : panelTop ,
301- height : panelHeight ,
302- ...( dockRight
303- ? { right : PANEL_EDGE , left : "auto" }
304- : { left : PANEL_EDGE , right : "auto" } ) ,
305- transform : isOpen ? "translateX(0)" : panelClosedTransform ,
306- } }
349+ style = {
350+ panelDragLeft !== null
351+ ? // During drag: position by absolute left px, no transform needed.
352+ {
353+ top : panelTop ,
354+ height : panelHeight ,
355+ left : panelDragLeft ,
356+ right : "auto" ,
357+ transform : "translateX(0)" ,
358+ }
359+ : // Resting: snap to the appropriate edge with slide-in/out transform.
360+ {
361+ top : panelTop ,
362+ height : panelHeight ,
363+ ...( dockRight
364+ ? { right : PANEL_EDGE , left : "auto" }
365+ : { left : PANEL_EDGE , right : "auto" } ) ,
366+ transform : isOpen ? "translateX(0)" : panelClosedTransform ,
367+ }
368+ }
307369 onPointerDown = { handlePanelPointerDown }
308370 onPointerMove = { handlePointerMove }
309371 onPointerUp = { endDrag }
0 commit comments