Skip to content

Commit 155651c

Browse files
committed
ui fixes (draggable, icon size)
1 parent 7b023ff commit 155651c

2 files changed

Lines changed: 295 additions & 13 deletions

File tree

apps/extension/src/components/FloatingPanel.tsx

Lines changed: 287 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,314 @@
1-
import { useState } from "react";
1+
import { useState, useRef, useEffect, useCallback } from "react";
22
import App from "../App";
33
import type { AppProps } from "../App";
44
import FloatingIcon from "../../public/floating_icon.svg?react";
5+
import { panelEvents } from "../panelBridge";
56

67
export interface FloatingPanelProps extends Pick<
78
AppProps,
89
"pageContext" | "onPreviewSlot"
910
> {}
1011

12+
// ── Layout constants ─────────────────────────────────────────────────────────
13+
// Icon is rendered at 80 % of the original 82×90 SVG.
14+
const ICON_W = 66;
15+
const ICON_H = 72;
16+
17+
// Mirror inset so the icon’s visible shape sits flush with the viewport edge
18+
// when snapped left or right (~6 px transparent padding at 80 % scale).
19+
const SNAP_EDGE_INSET = 6;
20+
21+
// Default launcher Y: ~old top-32 relative to a full-height panel from EDGE_GAP.
22+
const ICON_BELOW_PANEL = 112;
23+
24+
// Default panel top (full-height panel): aligns launcher with previous layout.
25+
const DEFAULT_PANEL_TOP = 16;
26+
27+
// Minimum gap from any viewport edge.
28+
const EDGE_GAP = 8;
29+
30+
/** Matches previous `right-4` / `left-4` panel inset. */
31+
const PANEL_EDGE = 16;
32+
33+
type DragSurface = "icon" | "panel";
34+
35+
// ── Helpers ─────────────────────────────────────────────────────────────────
36+
37+
/** X when snapped to the left edge (negative pulls past the edge for flush art). */
38+
function snapLeftX(): number {
39+
return -SNAP_EDGE_INSET;
40+
}
41+
42+
/** X when snapped to the right edge. */
43+
function snapRightX(): number {
44+
return window.innerWidth - ICON_W + SNAP_EDGE_INSET;
45+
}
46+
47+
function clampIconPos(x: number, y: number): { x: number; y: number } {
48+
return {
49+
x: Math.max(snapLeftX(), Math.min(snapRightX(), x)),
50+
y: Math.max(EDGE_GAP, Math.min(window.innerHeight - ICON_H - EDGE_GAP, y)),
51+
};
52+
}
53+
54+
/** Snap horizontal position to whichever side the icon’s center is closer to. */
55+
function magneticSnapX(x: number): number {
56+
const center = x + ICON_W / 2;
57+
return center < window.innerWidth / 2 ? snapLeftX() : snapRightX();
58+
}
59+
60+
function isPanelDragBlockedTarget(node: EventTarget | null): boolean {
61+
if (!(node instanceof Element)) return false;
62+
return (
63+
node.closest(
64+
"button, a, input, textarea, select, [contenteditable='true'], [role='button'], [role='tab']",
65+
) !== 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
81+
) {
82+
return false;
83+
}
84+
return true;
85+
}
86+
87+
// ── Component ────────────────────────────────────────────────────────────────
88+
1189
export default function FloatingPanel({
1290
pageContext,
1391
onPreviewSlot,
1492
}: FloatingPanelProps) {
1593
const [isOpen, setIsOpen] = useState(false);
94+
const [isDismissed, setIsDismissed] = useState(false);
95+
96+
const [iconPos, setIconPos] = useState(() =>
97+
clampIconPos(snapRightX(), DEFAULT_PANEL_TOP + ICON_BELOW_PANEL),
98+
);
99+
100+
const [isDragging, setIsDragging] = useState(false);
101+
102+
const panelRootRef = useRef<HTMLDivElement>(null);
103+
104+
/** Shared pointer-drag state (icon and panel use the same move / end logic). */
105+
const dragRef = useRef<{
106+
surface: DragSurface;
107+
pointerId: number;
108+
startClientX: number;
109+
startClientY: number;
110+
startX: number;
111+
startY: number;
112+
moved: boolean;
113+
} | null>(null);
114+
115+
// True = panel is anchored to the viewport right (icon on the right half).
116+
const dockRight = iconPos.x + ICON_W / 2 >= window.innerWidth / 2;
117+
118+
useEffect(() => {
119+
const handler = () => setIsDismissed(false);
120+
panelEvents.addEventListener("show", handler);
121+
return () => panelEvents.removeEventListener("show", handler);
122+
}, []);
123+
124+
useEffect(() => {
125+
function handleResize() {
126+
setIconPos((prev) => clampIconPos(magneticSnapX(prev.x), prev.y));
127+
}
128+
window.addEventListener("resize", handleResize);
129+
return () => window.removeEventListener("resize", handleResize);
130+
}, []);
131+
132+
const beginDrag = useCallback(
133+
(
134+
surface: DragSurface,
135+
e: React.PointerEvent,
136+
captureTarget: HTMLElement,
137+
) => {
138+
dragRef.current = {
139+
surface,
140+
pointerId: e.pointerId,
141+
startClientX: e.clientX,
142+
startClientY: e.clientY,
143+
startX: iconPos.x,
144+
startY: iconPos.y,
145+
moved: false,
146+
};
147+
setIsDragging(true);
148+
captureTarget.setPointerCapture(e.pointerId);
149+
},
150+
[iconPos.x, iconPos.y],
151+
);
152+
153+
const handlePointerMove = useCallback((e: React.PointerEvent) => {
154+
if (!dragRef.current || e.pointerId !== dragRef.current.pointerId) return;
155+
const d = dragRef.current;
156+
const dx = e.clientX - d.startClientX;
157+
const dy = e.clientY - d.startClientY;
158+
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) d.moved = true;
159+
setIconPos(clampIconPos(d.startX + dx, d.startY + dy));
160+
}, []);
161+
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;
171+
}
172+
173+
setIconPos((prev) => clampIconPos(magneticSnapX(prev.x), prev.y));
174+
}, []);
175+
176+
const handleLostPointerCapture = useCallback((e: React.PointerEvent) => {
177+
if (!dragRef.current || e.pointerId !== dragRef.current.pointerId) return;
178+
const { surface, moved } = dragRef.current;
179+
dragRef.current = null;
180+
setIsDragging(false);
181+
if (surface === "icon" && !moved) {
182+
setIsOpen(true);
183+
return;
184+
}
185+
setIconPos((prev) => clampIconPos(magneticSnapX(prev.x), prev.y));
186+
}, []);
187+
188+
const handleIconPointerDown = useCallback(
189+
(e: React.PointerEvent) => {
190+
if ((e.target as HTMLElement).closest("[data-dismiss-btn]")) return;
191+
if (e.button !== 0) return;
192+
beginDrag("icon", e, e.currentTarget as HTMLElement);
193+
},
194+
[beginDrag],
195+
);
196+
197+
const handlePanelPointerDown = useCallback(
198+
(e: React.PointerEvent) => {
199+
if (!isOpen) return;
200+
if (e.button !== 0) return;
201+
if (!isPanelDragAllowedStart(e.target)) return;
202+
const el = panelRootRef.current;
203+
if (el === null) return;
204+
beginDrag("panel", e, el);
205+
},
206+
[isOpen, beginDrag],
207+
);
208+
209+
// Panel is always full viewport height; launcher Y is independent.
210+
const panelTop = EDGE_GAP;
211+
const panelHeight = `calc(100vh - ${EDGE_GAP * 2}px)`;
212+
213+
const iconTransition = isDragging
214+
? "opacity 0.3s, filter 0.3s"
215+
: [
216+
"left 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)",
217+
"top 0.25s ease-out",
218+
"opacity 0.3s",
219+
"filter 0.3s",
220+
].join(", ");
221+
222+
const panelClosedTransform = dockRight
223+
? "translateX(calc(100% + 1rem))"
224+
: "translateX(calc(-100% - 1rem))";
225+
226+
if (isDismissed) return null;
16227

17228
return (
18229
<>
19-
{/* Floating tab — fades out when panel opens, fades in when closed */}
20-
<button
21-
onClick={() => setIsOpen(true)}
230+
{/* ── Floating tab ───────────────────────────────────────────────────── */}
231+
<div
232+
role="button"
233+
aria-label="Open Cornell Loop"
234+
tabIndex={0}
22235
className={[
23-
"fixed top-32 right-0 z-[9999] bg-transparent",
24-
"transition-[filter,opacity] duration-300 hover:brightness-95",
25-
isOpen ? "pointer-events-none opacity-0" : "opacity-100",
236+
"group fixed z-[9999] select-none",
237+
isOpen
238+
? "pointer-events-none opacity-0"
239+
: "cursor-grab opacity-100 active:cursor-grabbing",
26240
].join(" ")}
27-
aria-label="Open Cornell Loop"
241+
style={{ left: iconPos.x, top: iconPos.y, transition: iconTransition }}
242+
onPointerDown={handleIconPointerDown}
243+
onPointerMove={handlePointerMove}
244+
onPointerUp={endDrag}
245+
onPointerCancel={endDrag}
246+
onLostPointerCapture={handleLostPointerCapture}
247+
onKeyDown={(e) => {
248+
if (e.key === "Enter" || e.key === " ") setIsOpen(true);
249+
}}
28250
>
29-
<FloatingIcon className="h-[90px] w-[82px]" />
30-
</button>
251+
{dockRight ? (
252+
<FloatingIcon style={{ width: ICON_W, height: ICON_H }} />
253+
) : (
254+
<div
255+
style={{
256+
transform: "scaleX(-1)",
257+
width: ICON_W,
258+
height: ICON_H,
259+
}}
260+
>
261+
<FloatingIcon style={{ width: ICON_W, height: ICON_H }} />
262+
</div>
263+
)}
31264

32-
{/* Panel outer shell — slides in/out via inline transform (reliable in shadow DOM) */}
265+
<button
266+
data-dismiss-btn
267+
onClick={(e) => {
268+
e.stopPropagation();
269+
setIsDismissed(true);
270+
}}
271+
onPointerDown={(e) => e.stopPropagation()}
272+
className={[
273+
"absolute top-0 left-[3px] -translate-y-1/2",
274+
"flex h-[18px] w-[18px] items-center justify-center",
275+
"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",
277+
"text-[var(--color-brand)]",
278+
].join(" ")}
279+
aria-label="Dismiss Cornell Loop"
280+
>
281+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
282+
<path
283+
d="M8.5 8.5L1.5 1.5M8.5 1.5L1.5 8.5"
284+
stroke="currentColor"
285+
strokeWidth="1.75"
286+
strokeLinecap="round"
287+
/>
288+
</svg>
289+
</button>
290+
</div>
291+
292+
{/* ── Panel — full viewport height; dock follows launcher X ─────────── */}
33293
<div
34-
className="fixed top-4 right-4 bottom-4 z-[9998] w-[380px] transition-transform duration-300 ease-in-out"
294+
ref={panelRootRef}
295+
className={[
296+
"fixed z-[9998] w-[380px] overflow-hidden",
297+
"transition-transform duration-300 ease-in-out",
298+
].join(" ")}
35299
style={{
36-
transform: isOpen ? "translateX(0)" : "translateX(calc(100% + 1rem))",
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,
37306
}}
307+
onPointerDown={handlePanelPointerDown}
308+
onPointerMove={handlePointerMove}
309+
onPointerUp={endDrag}
310+
onPointerCancel={endDrag}
311+
onLostPointerCapture={handleLostPointerCapture}
38312
>
39313
<App
40314
onClose={() => setIsOpen(false)}

apps/extension/src/panelBridge.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* Module-level EventTarget used to signal state changes across the
3+
* content-script/component boundary without needing React refs or context.
4+
*
5+
* "show" — dispatched when the background service-worker notifies us that the
6+
* user clicked the toolbar icon while the panel was dismissed.
7+
*/
8+
export const panelEvents = new EventTarget();

0 commit comments

Comments
 (0)