Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions src/components/DetailPanelHeader/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
HEADER_ICON_SIZE,
} from "@src/config/workstation/tokens";

import { useWindowDrag } from "./useWindowDrag";
import { useWindowDrag } from "../FloatingWindow/useWindowDrag";

export interface DetailPanelHeaderProps {
/** Title to display */
Expand All @@ -30,8 +30,10 @@ export interface DetailPanelHeaderProps {
actions?: React.ReactNode;
/**
* When true, dragging the header repositions the nearest
* `[data-draggable-window]` ancestor. Used by the floating Kanban detail
* panel; docked panels leave this off.
* `[data-draggable-window]` ancestor, and the header drops its bottom
* border so the floating window reads as one continuous surface. Used by
* the floating windows (Kanban session preview, side chat); docked panels
* leave this off.
*/
draggable?: boolean;
}
Expand All @@ -50,7 +52,7 @@ const DetailPanelHeader: React.FC<DetailPanelHeaderProps> = ({
<div
className={
draggable
? `${HEADER_CLASSES.pageHeader} cursor-grab select-none`
? `${HEADER_CLASSES.pageHeader} cursor-grab select-none !border-b-0`
: HEADER_CLASSES.pageHeader
}
onPointerDown={onPointerDown}
Expand Down
56 changes: 56 additions & 0 deletions src/components/FloatingWindow/ResizeHandles.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* FloatingWindowResizeHandles
*
* Invisible edge/corner hit areas rendered inside a floating window surface
* (which is `overflow-hidden`, so they sit just inside the border). Corners
* win over edges by being rendered later at the same z-index tier.
*
* `data-no-window-drag` keeps the header drag hook from also claiming the
* north handle, which overlaps the draggable header strip.
*/
import React from "react";

import { type ResizeEdge, useWindowResize } from "./useWindowResize";

const HANDLES: ReadonlyArray<{ edge: ResizeEdge; className: string }> = [
{ edge: "n", className: "inset-x-2.5 top-0 h-[5px] cursor-ns-resize" },
{ edge: "s", className: "inset-x-2.5 bottom-0 h-[5px] cursor-ns-resize" },
{ edge: "e", className: "inset-y-2.5 right-0 w-[5px] cursor-ew-resize" },
{ edge: "w", className: "inset-y-2.5 left-0 w-[5px] cursor-ew-resize" },
{ edge: "nw", className: "left-0 top-0 h-2.5 w-2.5 cursor-nwse-resize" },
{ edge: "ne", className: "right-0 top-0 h-2.5 w-2.5 cursor-nesw-resize" },
{ edge: "sw", className: "bottom-0 left-0 h-2.5 w-2.5 cursor-nesw-resize" },
{ edge: "se", className: "bottom-0 right-0 h-2.5 w-2.5 cursor-nwse-resize" },
];

export interface FloatingWindowResizeHandlesProps {
minWidth: number;
minHeight: number;
maxWidth?: number;
maxHeight?: number;
}

const FloatingWindowResizeHandles: React.FC<
FloatingWindowResizeHandlesProps
> = ({ minWidth, minHeight, maxWidth, maxHeight }) => {
const startResize = useWindowResize({
minWidth,
minHeight,
maxWidth,
maxHeight,
});
return (
<>
{HANDLES.map(({ edge, className }) => (
<div
key={edge}
data-no-window-drag
onPointerDown={startResize(edge)}
className={`absolute z-30 touch-none select-none ${className}`}
/>
))}
</>
);
};

export default FloatingWindowResizeHandles;
94 changes: 94 additions & 0 deletions src/components/FloatingWindow/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* FloatingWindow
*
* Shared shell for floating in-pane windows (kanban session preview, chat
* pane side chat): a `pointer-events-none` overlay that defines the drag /
* resize bounds, plus a `data-draggable-window` surface inside it.
*
* - Dragging: give the window a header that spreads `useWindowDrag`'s
* handler (e.g. `<DetailPanelHeader draggable>`); the surface carries the
* `data-draggable-window` marker the hook looks for.
* - Resizing: enabled by default via invisible edge/corner handles. The
* first resize pins the surface to explicit px geometry; before that it
* follows whatever fluid CSS the caller's `surfaceClassName` sets up.
* - When the overlay itself resizes (app window, split-panel drag), a
* pinned surface is re-fitted so it can never be stranded off-screen.
*
* Callers own the look: pass the overlay class (positioning context, e.g.
* `WORK_MANAGEMENT_SESSION_PREVIEW_OVERLAY_CLASS`) and the surface class
* (initial size/anchor + chrome). The overlay must remain the surface's
* direct parent — both hooks clamp against it.
*/
import React, { useEffect, useRef } from "react";

import FloatingWindowResizeHandles from "./ResizeHandles";
import { fitPinnedWindow } from "./windowGeometry";

const DEFAULT_MIN_WIDTH = 360;
const DEFAULT_MIN_HEIGHT = 240;

export interface FloatingWindowProps {
/** Positioning context + anchoring for the window (pointer-events-none). */
overlayClassName: string;
/** Initial fluid geometry + chrome of the window surface itself. */
surfaceClassName: string;
/** Turn off the edge/corner resize handles (drag-only window). */
resizable?: boolean;
minWidth?: number;
minHeight?: number;
/** Optional px resize caps; the overlay bounds always apply on top. */
maxWidth?: number;
maxHeight?: number;
children: React.ReactNode;
}

const FloatingWindow: React.FC<FloatingWindowProps> = ({
overlayClassName,
surfaceClassName,
resizable = true,
minWidth = DEFAULT_MIN_WIDTH,
minHeight = DEFAULT_MIN_HEIGHT,
maxWidth,
maxHeight,
children,
}) => {
const overlayRef = useRef<HTMLDivElement>(null);
const surfaceRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!resizable) return;
const overlay = overlayRef.current;
const surface = surfaceRef.current;
if (!overlay || !surface) return;
const observer = new ResizeObserver(() => {
fitPinnedWindow(surface, minWidth, minHeight);
});
observer.observe(overlay);
return () => observer.disconnect();
}, [minHeight, minWidth, resizable]);

return (
<div ref={overlayRef} className={overlayClassName}>
<div
ref={surfaceRef}
// `relative` anchors the edge/corner resize handles to the surface
// (the overlay is the nearest positioned ancestor otherwise, and
// `overflow-hidden` would clip the handles into dead zones).
className={`relative ${surfaceClassName}`}
data-draggable-window
>
{children}
{resizable && (
<FloatingWindowResizeHandles
minWidth={minWidth}
minHeight={minHeight}
maxWidth={maxWidth}
maxHeight={maxHeight}
/>
)}
</div>
</div>
);
};

export default FloatingWindow;
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,27 @@
*
* The transform is applied imperatively to that ancestor's `style`, so a drag
* never re-renders the React tree — only one element's `transform` changes.
* The accumulated offset lives in a ref for the hook's lifetime, so the window
* keeps its position across re-renders (e.g. prev/next navigation) and resets
* naturally when the panel unmounts and re-opens.
* The accumulated offset lives on the window element itself (see
* `windowGeometry.ts`), so the position survives re-renders and header
* remounts, and resets naturally when the panel unmounts and re-opens.
*
* No-op when `enabled` is false or the handle has no `[data-draggable-window]`
* ancestor, so the same header stays inert on docked (non-floating) panels.
*/
import { useCallback, useEffect, useRef } from "react";

import {
applyWindowOffset,
clamp,
findFloatingWindow,
readWindowBounds,
readWindowOffset,
} from "./windowGeometry";

const INTERACTIVE_SELECTOR =
'button, a, input, select, textarea, [role="button"], [data-no-window-drag]';

interface Offset {
x: number;
y: number;
}

function clamp(value: number, min: number, max: number): number {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
}

export function useWindowDrag(enabled: boolean) {
const offsetRef = useRef<Offset>({ x: 0, y: 0 });
const cleanupRef = useRef<(() => void) | null>(null);

// Tear down listeners / restore the cursor if the panel unmounts mid-drag.
Expand All @@ -44,14 +41,12 @@ export function useWindowDrag(enabled: boolean) {
return;
}

const win = event.currentTarget.closest<HTMLElement>(
"[data-draggable-window]"
);
const win = findFloatingWindow(event.currentTarget);
if (!win) return;

const bounds = win.parentElement?.getBoundingClientRect() ?? null;
const bounds = readWindowBounds(win);
const rect = win.getBoundingClientRect();
const start = offsetRef.current;
const start = readWindowOffset(win);
// Untransformed origin of the window, so clamps read in viewport space.
const baseLeft = rect.left - start.x;
const baseTop = rect.top - start.y;
Expand All @@ -62,8 +57,8 @@ export function useWindowDrag(enabled: boolean) {
let nextX = start.x + (moveEvent.clientX - startX);
let nextY = start.y + (moveEvent.clientY - startY);
if (bounds) {
// Keep the whole window inside its overlay container (which clips
// overflow), so it can never be dragged out of sight.
// Keep the whole window inside its overlay's content box, so it
// can never be dragged out of sight or over the edge margin.
nextX = clamp(
nextX,
bounds.left - baseLeft,
Expand All @@ -75,8 +70,7 @@ export function useWindowDrag(enabled: boolean) {
bounds.bottom - rect.height - baseTop
);
}
offsetRef.current = { x: nextX, y: nextY };
win.style.transform = `translate3d(${nextX}px, ${nextY}px, 0)`;
applyWindowOffset(win, { x: nextX, y: nextY });
};

const finish = () => {
Expand Down
132 changes: 132 additions & 0 deletions src/components/FloatingWindow/useWindowResize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* useWindowResize
*
* Edge/corner resizing for a floating window (the nearest
* `[data-draggable-window]` ancestor of the handle). Returns a factory:
* `startResize("se")` gives the pointer-down handler for that handle.
*
* The first resize pins the window (explicit absolute px geometry — see
* `windowGeometry.ts`); from then on each edge moves independently of the
* window's original CSS centering. Like `useWindowDrag`, all writes go
* straight to the element's style, so resizing never re-renders React.
*/
import { useCallback, useEffect, useRef } from "react";

import {
clamp,
findFloatingWindow,
pinWindow,
readWindowBounds,
} from "./windowGeometry";

export type ResizeEdge = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";

const EDGE_CURSOR: Record<ResizeEdge, string> = {
n: "ns-resize",
s: "ns-resize",
e: "ew-resize",
w: "ew-resize",
ne: "nesw-resize",
sw: "nesw-resize",
nw: "nwse-resize",
se: "nwse-resize",
};

export interface UseWindowResizeOptions {
minWidth: number;
minHeight: number;
/** Optional px caps; the overlay bounds always apply on top. */
maxWidth?: number;
maxHeight?: number;
}

export function useWindowResize({
minWidth,
minHeight,
maxWidth = Number.POSITIVE_INFINITY,
maxHeight = Number.POSITIVE_INFINITY,
}: UseWindowResizeOptions) {
const cleanupRef = useRef<(() => void) | null>(null);

// Tear down listeners / restore the cursor if the panel unmounts mid-resize.
useEffect(() => () => cleanupRef.current?.(), []);

return useCallback(
(edge: ResizeEdge) => (event: React.PointerEvent<HTMLElement>) => {
if (event.button !== 0) return;
const win = findFloatingWindow(event.currentTarget);
if (!win) return;
const bounds = readWindowBounds(win);
if (!bounds) return;

event.preventDefault();
event.stopPropagation();

pinWindow(win);
const startLeft = Number.parseFloat(win.style.left) || 0;
const startTop = Number.parseFloat(win.style.top) || 0;
const startWidth = Number.parseFloat(win.style.width) || 0;
const startHeight = Number.parseFloat(win.style.height) || 0;
const startX = event.clientX;
const startY = event.clientY;

const handleMove = (moveEvent: PointerEvent) => {
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;

// The non-dragged edge stays fixed; the window never leaves the
// overlay's content box (padding = edge margin), mirroring the drag
// clamps, and never exceeds the configured px caps.
if (edge.includes("e")) {
const width = clamp(
startWidth + dx,
minWidth,
Math.min(bounds.maxRight - startLeft, maxWidth)
);
win.style.width = `${width}px`;
} else if (edge.includes("w")) {
const width = clamp(
startWidth - dx,
minWidth,
Math.min(startLeft + startWidth - bounds.minLeft, maxWidth)
);
win.style.width = `${width}px`;
win.style.left = `${startLeft + startWidth - width}px`;
}
if (edge.includes("s")) {
const height = clamp(
startHeight + dy,
minHeight,
Math.min(bounds.maxBottom - startTop, maxHeight)
);
win.style.height = `${height}px`;
} else if (edge.includes("n")) {
const height = clamp(
startHeight - dy,
minHeight,
Math.min(startTop + startHeight - bounds.minTop, maxHeight)
);
win.style.height = `${height}px`;
win.style.top = `${startTop + startHeight - height}px`;
}
};

const finish = () => {
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", finish);
window.removeEventListener("pointercancel", finish);
document.body.style.cursor = "";
document.body.style.userSelect = "";
cleanupRef.current = null;
};

cleanupRef.current = finish;
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", finish);
window.addEventListener("pointercancel", finish);
document.body.style.cursor = EDGE_CURSOR[edge];
document.body.style.userSelect = "none";
},
[minWidth, minHeight, maxWidth, maxHeight]
);
}
Loading
Loading