|
1 | | -import { useState } from "react"; |
| 1 | +import { useState, useRef, useEffect, useCallback } from "react"; |
2 | 2 | import App from "../App"; |
3 | 3 | import type { AppProps } from "../App"; |
4 | 4 | import FloatingIcon from "../../public/floating_icon.svg?react"; |
| 5 | +import { panelEvents } from "../panelBridge"; |
5 | 6 |
|
6 | 7 | export interface FloatingPanelProps extends Pick< |
7 | 8 | AppProps, |
8 | 9 | "pageContext" | "onPreviewSlot" |
9 | 10 | > {} |
10 | 11 |
|
| 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 | + |
11 | 89 | export default function FloatingPanel({ |
12 | 90 | pageContext, |
13 | 91 | onPreviewSlot, |
14 | 92 | }: FloatingPanelProps) { |
15 | 93 | 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; |
16 | 227 |
|
17 | 228 | return ( |
18 | 229 | <> |
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} |
22 | 235 | 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", |
26 | 240 | ].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 | + }} |
28 | 250 | > |
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 | + )} |
31 | 264 |
|
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 ─────────── */} |
33 | 293 | <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(" ")} |
35 | 299 | 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, |
37 | 306 | }} |
| 307 | + onPointerDown={handlePanelPointerDown} |
| 308 | + onPointerMove={handlePointerMove} |
| 309 | + onPointerUp={endDrag} |
| 310 | + onPointerCancel={endDrag} |
| 311 | + onLostPointerCapture={handleLostPointerCapture} |
38 | 312 | > |
39 | 313 | <App |
40 | 314 | onClose={() => setIsOpen(false)} |
|
0 commit comments