From a96edc116f982abac6c57c66f5875eb175377c38 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 14 May 2026 23:07:29 -0400 Subject: [PATCH] feat: resizable PiP call window Add 4 corner resize handles to the PiP window (SE/SW/NE/NW). Each handle shows a 3-dot grip indicator and sets the appropriate resize cursor. Resize handles sit above the drag overlay (zIndex 2) and stop propagation so they do not trigger drag-to-move. On resize start the element is normalised to top/left positioning so math is consistent regardless of whether bottom/right was active. Minimum size 200x112px. Viewport clamped. --- src/app/components/CallEmbedProvider.tsx | 199 +++++++++++++++++++---- 1 file changed, 164 insertions(+), 35 deletions(-) diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 4286a89cf..07f15b0f6 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -15,6 +15,9 @@ import { useSelectedRoom } from '../hooks/router/useSelectedRoom'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; import { useRoomNavigate } from '../hooks/useRoomNavigate'; +const PIP_MIN_W = 200; +const PIP_MIN_H = 112; // keeps roughly 16:9 at minimum + function CallUtils({ embed }: { embed: CallEmbed }) { const setCallEmbed = useSetAtom(callEmbedAtom); @@ -30,6 +33,19 @@ function CallUtils({ embed }: { embed: CallEmbed }) { return null; } +type Corner = 'se' | 'sw' | 'ne' | 'nw'; + +/** Normalise the element to top/left positioning so resize math is uniform. */ +function normaliseToTopLeft(el: HTMLDivElement) { + const rect = el.getBoundingClientRect(); + el.style.top = `${rect.top}px`; + el.style.left = `${rect.left}px`; + el.style.right = 'auto'; + el.style.bottom = 'auto'; + el.style.width = `${rect.width}px`; + el.style.height = `${rect.height}px`; +} + type CallEmbedProviderProps = { children?: ReactNode; }; @@ -49,7 +65,6 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { const callVisible = inCallRoom && callActive && !chatOnlyView; const pipMode = callActive && !inCallRoom; - // Tracks whether the current pointer-down turned into a drag vs a tap/click. const pipDragRef = useRef<{ startX: number; startY: number; @@ -58,8 +73,8 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { dragged: boolean; } | null>(null); - // useCallEmbedPlacementSync writes top/left/width/height as inline styles directly on this - // element. Override those when in PiP mode; let them be restored when returning to the call room. + // useCallEmbedPlacementSync writes top/left/width/height directly on this element. + // Override those in PiP mode; clear them when returning so it can take back control. useEffect(() => { const el = callEmbedRef.current; if (!el) return; @@ -77,11 +92,12 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { el.style.border = '1px solid rgba(255,255,255,0.1)'; el.style.visibility = 'visible'; } else { - // Clear all PiP overrides so useCallEmbedPlacementSync can take back control. el.style.top = ''; el.style.left = ''; el.style.bottom = ''; el.style.right = ''; + el.style.width = ''; + el.style.height = ''; el.style.borderRadius = ''; el.style.overflow = ''; el.style.zIndex = ''; @@ -91,6 +107,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { } }, [pipMode, callVisible]); + // ── Drag to move ──────────────────────────────────────────────────────────── const handlePipMouseDown = (e: React.MouseEvent) => { const el = callEmbedRef.current; if (!el) return; @@ -127,7 +144,6 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { document.removeEventListener('mouseup', onMouseUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; - // Defer clearing the dragged flag so the onClick handler fires first. setTimeout(() => { if (pipDragRef.current) pipDragRef.current.dragged = false; }, 0); @@ -186,6 +202,102 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { navigateRoom(roomId); }; + // ── Resize from corner handles ─────────────────────────────────────────────── + const handleResizeMouseDown = (e: React.MouseEvent, corner: Corner) => { + e.stopPropagation(); + e.preventDefault(); + const el = callEmbedRef.current; + if (!el) return; + + normaliseToTopLeft(el); + + const startX = e.clientX; + const startY = e.clientY; + const startW = el.offsetWidth; + const startH = el.offsetHeight; + const startL = parseFloat(el.style.left); + const startT = parseFloat(el.style.top); + + document.body.style.cursor = `${corner}-resize`; + document.body.style.userSelect = 'none'; + + const onMouseMove = (ev: MouseEvent) => { + const dx = ev.clientX - startX; + const dy = ev.clientY - startY; + + let w = startW; + let h = startH; + let l = startL; + let t = startT; + + if (corner === 'se') { w = startW + dx; h = startH + dy; } + if (corner === 'sw') { w = startW - dx; h = startH + dy; l = startL + startW - Math.max(PIP_MIN_W, w); } + if (corner === 'ne') { w = startW + dx; h = startH - dy; t = startT + startH - Math.max(PIP_MIN_H, h); } + if (corner === 'nw') { w = startW - dx; h = startH - dy; l = startL + startW - Math.max(PIP_MIN_W, w); t = startT + startH - Math.max(PIP_MIN_H, h); } + + w = Math.max(PIP_MIN_W, Math.min(w, window.innerWidth)); + h = Math.max(PIP_MIN_H, Math.min(h, window.innerHeight)); + l = Math.max(0, Math.min(l, window.innerWidth - PIP_MIN_W)); + t = Math.max(0, Math.min(t, window.innerHeight - PIP_MIN_H)); + + el.style.width = `${w}px`; + el.style.height = `${h}px`; + el.style.left = `${l}px`; + el.style.top = `${t}px`; + }; + + const onMouseUp = () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }; + + // Corner handle style helper + const cornerHandle = (corner: Corner): React.CSSProperties => { + const s = corner.includes('s'); + const e = corner.includes('e'); + return { + position: 'absolute', + width: '18px', + height: '18px', + [s ? 'bottom' : 'top']: 0, + [e ? 'right' : 'left']: 0, + cursor: `${corner}-resize`, + zIndex: 2, + }; + }; + + // Grip dot pattern rendered inside each handle + const gripDots = (corner: Corner) => { + const s = corner.includes('s'); + const e = corner.includes('e'); + // 3 dots arranged in an L toward the active corner + const dots: React.CSSProperties[] = []; + const r = 2; // dot radius px + const gap = 5; + const base = 3; + for (let i = 0; i < 3; i++) { + for (let j = 0; j < 3; j++) { + if (i + j < 2) continue; // only the 3 corner-most dots + dots.push({ + position: 'absolute', + width: r * 2, + height: r * 2, + borderRadius: '50%', + background: 'rgba(255,255,255,0.45)', + [s ? 'bottom' : 'top']: base + i * gap, + [e ? 'right' : 'left']: base + j * gap, + }); + } + } + return dots; + }; + return ( {callEmbed && } @@ -203,41 +315,58 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { }} > {pipMode && callEmbed && ( -
handlePipClick(callEmbed.roomId)} - onKeyDown={(e) => e.key === 'Enter' && handlePipClick(callEmbed.roomId)} - style={{ - position: 'absolute', - inset: 0, - zIndex: 1, - background: 'transparent', - cursor: 'grab', - display: 'flex', - alignItems: 'flex-start', - justifyContent: 'flex-end', - padding: '6px', - }} - > + <> + {/* Drag-to-move overlay (zIndex 1, sits over the iframe) */}
handlePipClick(callEmbed.roomId)} + onKeyDown={(e) => e.key === 'Enter' && handlePipClick(callEmbed.roomId)} style={{ - background: 'rgba(0,0,0,0.65)', - backdropFilter: 'blur(4px)', - borderRadius: '6px', - padding: '3px 8px', - color: '#fff', - fontSize: '11px', - fontWeight: 600, - pointerEvents: 'none', + position: 'absolute', + inset: 0, + zIndex: 1, + background: 'transparent', + cursor: 'grab', + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'flex-end', + padding: '6px', }} > - ↗ Return to call +
+ ↗ Return to call +
-
+ + {/* Corner resize handles (zIndex 2, above the drag overlay) */} + {(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => ( +
handleResizeMouseDown(e, corner)} + onClick={(e) => e.stopPropagation()} + > + {gripDots(corner).map((style, i) => ( +
+ ))} +
+ ))} + )}