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 { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||||
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
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 }) {
|
function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||||
|
|
||||||
@@ -30,6 +33,19 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
|
|||||||
return null;
|
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 = {
|
type CallEmbedProviderProps = {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
};
|
};
|
||||||
@@ -49,7 +65,6 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
const callVisible = inCallRoom && callActive && !chatOnlyView;
|
const callVisible = inCallRoom && callActive && !chatOnlyView;
|
||||||
const pipMode = callActive && !inCallRoom;
|
const pipMode = callActive && !inCallRoom;
|
||||||
|
|
||||||
// Tracks whether the current pointer-down turned into a drag vs a tap/click.
|
|
||||||
const pipDragRef = useRef<{
|
const pipDragRef = useRef<{
|
||||||
startX: number;
|
startX: number;
|
||||||
startY: number;
|
startY: number;
|
||||||
@@ -58,8 +73,8 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
dragged: boolean;
|
dragged: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// useCallEmbedPlacementSync writes top/left/width/height as inline styles directly on this
|
// useCallEmbedPlacementSync writes top/left/width/height directly on this element.
|
||||||
// element. Override those when in PiP mode; let them be restored when returning to the call room.
|
// Override those in PiP mode; clear them when returning so it can take back control.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = callEmbedRef.current;
|
const el = callEmbedRef.current;
|
||||||
if (!el) return;
|
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.border = '1px solid rgba(255,255,255,0.1)';
|
||||||
el.style.visibility = 'visible';
|
el.style.visibility = 'visible';
|
||||||
} else {
|
} else {
|
||||||
// Clear all PiP overrides so useCallEmbedPlacementSync can take back control.
|
|
||||||
el.style.top = '';
|
el.style.top = '';
|
||||||
el.style.left = '';
|
el.style.left = '';
|
||||||
el.style.bottom = '';
|
el.style.bottom = '';
|
||||||
el.style.right = '';
|
el.style.right = '';
|
||||||
|
el.style.width = '';
|
||||||
|
el.style.height = '';
|
||||||
el.style.borderRadius = '';
|
el.style.borderRadius = '';
|
||||||
el.style.overflow = '';
|
el.style.overflow = '';
|
||||||
el.style.zIndex = '';
|
el.style.zIndex = '';
|
||||||
@@ -91,6 +107,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
}
|
}
|
||||||
}, [pipMode, callVisible]);
|
}, [pipMode, callVisible]);
|
||||||
|
|
||||||
|
// ── Drag to move ────────────────────────────────────────────────────────────
|
||||||
const handlePipMouseDown = (e: React.MouseEvent) => {
|
const handlePipMouseDown = (e: React.MouseEvent) => {
|
||||||
const el = callEmbedRef.current;
|
const el = callEmbedRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -127,7 +144,6 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
document.removeEventListener('mouseup', onMouseUp);
|
document.removeEventListener('mouseup', onMouseUp);
|
||||||
document.body.style.cursor = '';
|
document.body.style.cursor = '';
|
||||||
document.body.style.userSelect = '';
|
document.body.style.userSelect = '';
|
||||||
// Defer clearing the dragged flag so the onClick handler fires first.
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (pipDragRef.current) pipDragRef.current.dragged = false;
|
if (pipDragRef.current) pipDragRef.current.dragged = false;
|
||||||
}, 0);
|
}, 0);
|
||||||
@@ -186,6 +202,102 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
navigateRoom(roomId);
|
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 (
|
return (
|
||||||
<CallEmbedContextProvider value={callEmbed}>
|
<CallEmbedContextProvider value={callEmbed}>
|
||||||
{callEmbed && <CallUtils embed={callEmbed} />}
|
{callEmbed && <CallUtils embed={callEmbed} />}
|
||||||
@@ -203,41 +315,58 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{pipMode && callEmbed && (
|
{pipMode && callEmbed && (
|
||||||
<div
|
<>
|
||||||
role="button"
|
{/* Drag-to-move overlay (zIndex 1, sits over the iframe) */}
|
||||||
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',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<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={{
|
style={{
|
||||||
background: 'rgba(0,0,0,0.65)',
|
position: 'absolute',
|
||||||
backdropFilter: 'blur(4px)',
|
inset: 0,
|
||||||
borderRadius: '6px',
|
zIndex: 1,
|
||||||
padding: '3px 8px',
|
background: 'transparent',
|
||||||
color: '#fff',
|
cursor: 'grab',
|
||||||
fontSize: '11px',
|
display: 'flex',
|
||||||
fontWeight: 600,
|
alignItems: 'flex-start',
|
||||||
pointerEvents: 'none',
|
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>
|
||||||
</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>
|
</div>
|
||||||
</CallEmbedContextProvider>
|
</CallEmbedContextProvider>
|
||||||
|
|||||||
Reference in New Issue
Block a user