/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
/**
* Live "grab the grip to detach a panel" gesture (#1208), shared by the
* sidebar pane and the bottom strip.
*
* On the first move past the threshold the panel lifts straight out of its dock
* into a floating window (#1201) positioned exactly where it was, then tracks
* the cursor for the whole gesture — no disappear-until-drop. Release inside
* the viewport → it stays floating where dropped; release past the window edge
* (e.g. dragging onto another monitor) → it hands off to an OS / Picture-in-
* Picture window. Pointer capture on
keeps the gesture alive after the
* grip's host unmounts and while the cursor leaves the window.
*
* The source rect is read from the nearest `[data-detach-root]` ancestor of the
* grip, so the float starts exactly over the panel it came from.
*/
import { useCallback, type PointerEvent as ReactPointerEvent } from 'react';
import { useViewerStore } from '@/store';
import type { WorkspacePanelId } from '@/lib/panels/registry';
import { usePanelControls } from './usePanelControls';
const DRAG_THRESHOLD = 5;
function isPointerOutsideWindow(x: number, y: number): boolean {
return x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight;
}
export function usePanelDetachDrag(id: WorkspacePanelId): (e: ReactPointerEvent) => void {
const { floatPanel, popOutPanel } = usePanelControls();
return useCallback(
(e) => {
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest('[data-chrome-btn]')) return; // skip nested buttons
e.preventDefault();
const root = (e.currentTarget as HTMLElement).closest('[data-detach-root]') as HTMLElement | null;
const rect = root?.getBoundingClientRect();
// Cap the lifted float to a sane window size — the bottom strip spans the
// whole viewport width, which would otherwise make a huge full-width float.
const w = rect ? Math.min(Math.round(rect.width), 720) : 360;
const h = rect ? Math.min(Math.round(rect.height), 600) : 460;
const baseX = rect ? rect.left : e.clientX - 40;
const baseY = rect ? rect.top : e.clientY - 10;
const startX = e.clientX;
const startY = e.clientY;
const pid = e.pointerId;
let started = false;
const place = (cx: number, cy: number) => {
useViewerStore.getState().setFloatingPanelRect(id, {
x: baseX + (cx - startX),
y: baseY + (cy - startY),
w,
h,
});
};
const onMove = (ev: PointerEvent) => {
if (!started) {
if (Math.hypot(ev.clientX - startX, ev.clientY - startY) <= DRAG_THRESHOLD) return;
started = true;
floatPanel(id); // lift into a live float, same tick as positioning
place(ev.clientX, ev.clientY);
document.body.style.cursor = 'grabbing';
try { document.body.setPointerCapture(pid); } catch { /* keeps tracking outside the window */ }
} else {
place(ev.clientX, ev.clientY);
}
};
const onUp = (ev: PointerEvent) => {
window.removeEventListener('pointermove', onMove, true);
window.removeEventListener('pointerup', onUp, true);
window.removeEventListener('pointercancel', onUp, true);
document.body.style.cursor = '';
try { document.body.releasePointerCapture(pid); } catch { /* noop */ }
if (started && isPointerOutsideWindow(ev.clientX, ev.clientY)) {
// Dragged off the window (onto another screen) → hand off to an OS / PiP window.
useViewerStore.getState().closeFloatingPanel(id);
popOutPanel(id);
}
};
window.addEventListener('pointermove', onMove, true);
window.addEventListener('pointerup', onUp, true);
window.addEventListener('pointercancel', onUp, true);
},
[floatPanel, popOutPanel, id],
);
}