import { useEffect, useId, useRef, useState, type HTMLAttributes, type KeyboardEvent, type PointerEvent, type ReactNode, type RefObject, } from "react"; import { cn } from "./cn.js"; import { Icon } from "./Icon.js"; export interface WorkspaceWindowRect { x: number; y: number; width: number; height: number; } export interface WorkspaceWindowProps extends Omit, "title" | "onResize"> { boundsRef: RefObject; rect: WorkspaceWindowRect; onRectChange: (rect: WorkspaceWindowRect) => void; maximized: boolean; onMaximizedChange: (maximized: boolean) => void; onActivate?: () => void; onClose: () => void; title: ReactNode; subtitle?: ReactNode; status?: ReactNode; footer?: ReactNode; children: ReactNode; minWidth?: number; minHeight?: number; resizable?: boolean; autoHeight?: boolean; active?: boolean; zIndex?: number; closeLabel?: string; maximizeLabel?: string; restoreLabel?: string; moveLabel?: string; resizeLabel?: string; } type WorkspaceBounds = { width: number; height: number }; type WorkspaceInteraction = { kind: "move" | "resize"; pointerId: number; startClientX: number; startClientY: number; startRect: WorkspaceWindowRect; previewRect: WorkspaceWindowRect; startInlineTransform: string; }; const rectEquals = (left: WorkspaceWindowRect, right: WorkspaceWindowRect) => ( left.x === right.x && left.y === right.y && left.width === right.width && left.height === right.height ); const clamp = (value: number, minimum: number, maximum: number) => ( Math.min(Math.max(value, minimum), maximum) ); const clampDimension = (value: number, minimum: number, available: number) => { const limit = Math.max(0, available); const effectiveMinimum = Math.min(Math.max(0, minimum), limit); return clamp(value, effectiveMinimum, limit); }; const normalizeRect = ( rect: WorkspaceWindowRect, bounds: WorkspaceBounds, minWidth: number, minHeight: number, ): WorkspaceWindowRect => { const width = clampDimension(rect.width, minWidth, bounds.width); const height = clampDimension(rect.height, minHeight, bounds.height); return { x: clamp(rect.x, 0, Math.max(0, bounds.width - width)), y: clamp(rect.y, 0, Math.max(0, bounds.height - height)), width, height, }; }; const resizeRect = ( rect: WorkspaceWindowRect, deltaX: number, deltaY: number, bounds: WorkspaceBounds, minWidth: number, minHeight: number, ): WorkspaceWindowRect => { const normalized = normalizeRect(rect, bounds, minWidth, minHeight); return { ...normalized, width: clampDimension(normalized.width + deltaX, minWidth, bounds.width - normalized.x), height: clampDimension(normalized.height + deltaY, minHeight, bounds.height - normalized.y), }; }; const keyboardStep = (event: KeyboardEvent) => event.shiftKey ? 10 : 1; export function WorkspaceWindow({ boundsRef, rect, onRectChange, maximized, onMaximizedChange, onActivate, onClose, title, subtitle, status, footer, children, minWidth = 260, minHeight = 180, resizable = true, autoHeight = false, active = false, zIndex, closeLabel = "Закрыть окно", maximizeLabel = "Развернуть окно", restoreLabel = "Восстановить окно", moveLabel = "Переместить окно", resizeLabel = "Изменить размер окна", className, style, onPointerDownCapture, onFocusCapture, ...props }: WorkspaceWindowProps) { const titleId = useId(); const descriptionId = useId(); const rootRef = useRef(null); const interactionRef = useRef(null); const interactionFrameRef = useRef(0); const controlledRectRef = useRef(rect); const rectRef = useRef(rect); const onRectChangeRef = useRef(onRectChange); const headRef = useRef(null); const bodyRef = useRef(null); const bodyContentRef = useRef(null); const footerRef = useRef(null); const [interactionKind, setInteractionKind] = useState(null); controlledRectRef.current = rect; if (!interactionRef.current) rectRef.current = rect; onRectChangeRef.current = onRectChange; const readBounds = (): WorkspaceBounds | null => { const bounds = boundsRef.current; if (!bounds) return null; return { width: bounds.clientWidth, height: bounds.clientHeight }; }; const emitRect = (nextRect: WorkspaceWindowRect) => { if (rectEquals(rectRef.current, nextRect)) return; rectRef.current = nextRect; onRectChangeRef.current(nextRect); }; useEffect(() => { const bounds = boundsRef.current; if (!bounds || maximized) return; const clampToBounds = () => { if (interactionRef.current) return; const nextBounds = readBounds(); if (nextBounds) emitRect(normalizeRect(rectRef.current, nextBounds, minWidth, minHeight)); }; clampToBounds(); const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(clampToBounds); observer?.observe(bounds); window.addEventListener("resize", clampToBounds); return () => { observer?.disconnect(); window.removeEventListener("resize", clampToBounds); }; }, [boundsRef, maximized, minHeight, minWidth, rect.height, rect.width, rect.x, rect.y]); useEffect(() => { if (!autoHeight || maximized) return; const bounds = boundsRef.current; const head = headRef.current; const body = bodyRef.current; const content = bodyContentRef.current; if (!bounds || !head || !body || !content) return; let frame = 0; const fitContent = () => { frame = 0; if (interactionRef.current) return; const nextBounds = readBounds(); if (!nextBounds) return; const normalized = normalizeRect(rectRef.current, nextBounds, minWidth, minHeight); const bodyStyle = window.getComputedStyle(body); const bodyPadding = Number.parseFloat(bodyStyle.paddingTop || "0") + Number.parseFloat(bodyStyle.paddingBottom || "0"); const desiredHeight = Math.ceil( head.offsetHeight + content.scrollHeight + bodyPadding + (footerRef.current?.offsetHeight ?? 0), ); const availableHeight = Math.max(0, nextBounds.height - normalized.y); emitRect({ ...normalized, height: clampDimension(desiredHeight, minHeight, availableHeight), }); }; const scheduleFit = () => { if (frame) cancelAnimationFrame(frame); frame = requestAnimationFrame(fitContent); }; scheduleFit(); const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleFit); observer?.observe(content); observer?.observe(head); if (footerRef.current) observer?.observe(footerRef.current); return () => { if (frame) cancelAnimationFrame(frame); observer?.disconnect(); }; }, [autoHeight, boundsRef, maximized, minHeight, minWidth, rect.width]); useEffect(() => { const paintInteraction = () => { interactionFrameRef.current = 0; const interaction = interactionRef.current; const root = rootRef.current; if (!interaction || !root) return; if (interaction.kind === "move") { const deltaX = interaction.previewRect.x - interaction.startRect.x; const deltaY = interaction.previewRect.y - interaction.startRect.y; const prefix = interaction.startInlineTransform ? `${interaction.startInlineTransform} ` : ""; root.style.transform = `${prefix}translate3d(${deltaX}px, ${deltaY}px, 0)`; return; } root.style.width = `${interaction.previewRect.width}px`; root.style.height = `${interaction.previewRect.height}px`; }; const scheduleInteractionPaint = () => { if (interactionFrameRef.current) return; interactionFrameRef.current = window.requestAnimationFrame(paintInteraction); }; const handlePointerMove = (event: globalThis.PointerEvent) => { const interaction = interactionRef.current; if (!interaction || event.pointerId !== interaction.pointerId) return; const bounds = readBounds(); if (!bounds) return; const deltaX = event.clientX - interaction.startClientX; const deltaY = event.clientY - interaction.startClientY; const nextRect = interaction.kind === "move" ? normalizeRect({ ...interaction.startRect, x: interaction.startRect.x + deltaX, y: interaction.startRect.y + deltaY, }, bounds, minWidth, minHeight) : resizeRect(interaction.startRect, deltaX, deltaY, bounds, minWidth, minHeight); interaction.previewRect = nextRect; rectRef.current = nextRect; scheduleInteractionPaint(); event.preventDefault(); }; const endInteraction = (event: globalThis.PointerEvent) => { const interaction = interactionRef.current; if (!interaction || interaction.pointerId !== event.pointerId) return; if (interactionFrameRef.current) { window.cancelAnimationFrame(interactionFrameRef.current); interactionFrameRef.current = 0; } paintInteraction(); const nextRect = interaction.previewRect; const root = rootRef.current; if (root) { root.style.left = `${nextRect.x}px`; root.style.top = `${nextRect.y}px`; root.style.width = `${nextRect.width}px`; root.style.height = `${nextRect.height}px`; root.style.transform = interaction.startInlineTransform; } interactionRef.current = null; setInteractionKind(null); if (!rectEquals(controlledRectRef.current, nextRect)) onRectChangeRef.current(nextRect); }; window.addEventListener("pointermove", handlePointerMove, { passive: false }); window.addEventListener("pointerup", endInteraction); window.addEventListener("pointercancel", endInteraction); return () => { if (interactionFrameRef.current) window.cancelAnimationFrame(interactionFrameRef.current); interactionFrameRef.current = 0; window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", endInteraction); window.removeEventListener("pointercancel", endInteraction); }; }, [boundsRef, minHeight, minWidth]); useEffect(() => { if ((!resizable && interactionRef.current?.kind === "resize") || maximized) { interactionRef.current = null; setInteractionKind(null); } }, [maximized, resizable]); const beginInteraction = (kind: WorkspaceInteraction["kind"], event: PointerEvent) => { if (interactionRef.current || maximized || event.button !== 0 || (kind === "resize" && !resizable)) return; if (kind === "move" && (event.target as HTMLElement).closest("button, input, select, textarea, a")) return; const bounds = readBounds(); if (!bounds) return; const startRect = normalizeRect(rectRef.current, bounds, minWidth, minHeight); interactionRef.current = { kind, pointerId: event.pointerId, startClientX: event.clientX, startClientY: event.clientY, startRect, previewRect: startRect, startInlineTransform: rootRef.current?.style.transform ?? "", }; rectRef.current = startRect; setInteractionKind(kind); event.currentTarget.focus(); event.preventDefault(); }; const handleMoveKeyDown = (event: KeyboardEvent) => { if (maximized || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return; const bounds = readBounds(); if (!bounds) return; const step = keyboardStep(event); const next = { ...rectRef.current }; if (event.key === "ArrowLeft") next.x -= step; if (event.key === "ArrowRight") next.x += step; if (event.key === "ArrowUp") next.y -= step; if (event.key === "ArrowDown") next.y += step; emitRect(normalizeRect(next, bounds, minWidth, minHeight)); event.preventDefault(); }; const handleResizeKeyDown = (event: KeyboardEvent) => { if (maximized || !resizable || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return; const bounds = readBounds(); if (!bounds) return; const step = keyboardStep(event); const deltaX = event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0; const deltaY = event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0; emitRect(resizeRect(rectRef.current, deltaX, deltaY, bounds, minWidth, minHeight)); event.preventDefault(); }; const geometryStyle = maximized ? { left: 0, top: 0, width: "100%", height: "100%" } : { left: rect.x, top: rect.y, width: rect.width, height: rect.height }; return (
{ onActivate?.(); onPointerDownCapture?.(event); }} onFocusCapture={(event) => { onActivate?.(); onFocusCapture?.(event); }} >
beginInteraction("move", event)} onKeyDown={handleMoveKeyDown} >

{title}

{status ? {status} : null}
{subtitle ?

{subtitle}

: null}
{autoHeight ?
{children}
: children}
{footer ?
{footer}
: null} {resizable && !maximized ? (
); }