Skip to content

Commit bbd7792

Browse files
anchen9cursoragent
andcommitted
Address PR #11 review comments on FloatingPanel
- Panel drag now uses panelDragLeft state so the panel visually follows the cursor; iconPos is only updated at snap time, eliminating the side-flicker described in review comment #1. - LOOP_SHOW_PANEL handler now calls setIsOpen(true) in addition to clearing isDismissed, so toolbar clicks re-open the panel directly. - Dismiss button gains focus-visible:opacity-100 so keyboard users can see the control they are focused on. - Space key on the launcher div now calls e.preventDefault() to prevent the host page from scrolling. - Replaced (e.target as HTMLElement) cast with an instanceof Element guard in handleIconPointerDown. - Dismiss button position flips based on dockRight so the X always appears at the outer/viewport-edge top corner on both sides. - Add grab/grabbing cursor affordance to the panel header drag zone via content.css. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent cee0ffd commit bbd7792

2 files changed

Lines changed: 128 additions & 59 deletions

File tree

apps/extension/src/components/FloatingPanel.tsx

Lines changed: 121 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ const EDGE_GAP = 8;
3030
/** Matches previous `right-4` / `left-4` panel inset. */
3131
const 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+
3336
type 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}

apps/extension/src/content.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,10 @@
1717
border-style: solid;
1818
}
1919
}
20+
21+
[data-loop-panel-drag] {
22+
cursor: grab;
23+
}
24+
.loop-panel-dragging [data-loop-panel-drag] {
25+
cursor: grabbing;
26+
}

0 commit comments

Comments
 (0)