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.
This commit is contained in:
@@ -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 (
|
||||
<CallEmbedContextProvider value={callEmbed}>
|
||||
{callEmbed && <CallUtils embed={callEmbed} />}
|
||||
@@ -203,41 +315,58 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
}}
|
||||
>
|
||||
{pipMode && callEmbed && (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Return to call"
|
||||
onMouseDown={handlePipMouseDown}
|
||||
onTouchStart={handlePipTouchStart}
|
||||
onClick={() => 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) */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Return to call"
|
||||
onMouseDown={handlePipMouseDown}
|
||||
onTouchStart={handlePipTouchStart}
|
||||
onClick={() => 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
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.65)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
borderRadius: '6px',
|
||||
padding: '3px 8px',
|
||||
color: '#fff',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
↗ Return to call
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Corner resize handles (zIndex 2, above the drag overlay) */}
|
||||
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => (
|
||||
<div
|
||||
key={corner}
|
||||
style={cornerHandle(corner)}
|
||||
onMouseDown={(e) => handleResizeMouseDown(e, corner)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{gripDots(corner).map((style, i) => (
|
||||
<div key={i} style={style} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CallEmbedContextProvider>
|
||||
|
||||
Reference in New Issue
Block a user