452 lines
16 KiB
TypeScript
452 lines
16 KiB
TypeScript
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<HTMLAttributes<HTMLDivElement>, "title" | "onResize"> {
|
|
boundsRef: RefObject<HTMLElement | null>;
|
|
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<HTMLElement>) => 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<HTMLDivElement>(null);
|
|
const interactionRef = useRef<WorkspaceInteraction | null>(null);
|
|
const interactionFrameRef = useRef(0);
|
|
const controlledRectRef = useRef(rect);
|
|
const rectRef = useRef(rect);
|
|
const onRectChangeRef = useRef(onRectChange);
|
|
const headRef = useRef<HTMLElement>(null);
|
|
const bodyRef = useRef<HTMLDivElement>(null);
|
|
const bodyContentRef = useRef<HTMLDivElement>(null);
|
|
const footerRef = useRef<HTMLElement>(null);
|
|
const [interactionKind, setInteractionKind] = useState<WorkspaceInteraction["kind"] | null>(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<HTMLElement>) => {
|
|
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<HTMLElement>) => {
|
|
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<HTMLButtonElement>) => {
|
|
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 (
|
|
<div
|
|
ref={rootRef}
|
|
{...props}
|
|
className={cn("nodedc-workspace-window nodedc-glass-material nodedc-ui-root", className)}
|
|
data-active={active ? "true" : undefined}
|
|
data-maximized={maximized ? "true" : undefined}
|
|
data-resizable={resizable ? "true" : undefined}
|
|
data-auto-height={autoHeight ? "true" : undefined}
|
|
data-interaction={interactionKind ?? undefined}
|
|
role="dialog"
|
|
aria-modal="false"
|
|
aria-labelledby={titleId}
|
|
aria-describedby={subtitle ? descriptionId : undefined}
|
|
style={{ ...style, ...geometryStyle, zIndex: zIndex ?? style?.zIndex }}
|
|
onPointerDownCapture={(event) => {
|
|
onActivate?.();
|
|
onPointerDownCapture?.(event);
|
|
}}
|
|
onFocusCapture={(event) => {
|
|
onActivate?.();
|
|
onFocusCapture?.(event);
|
|
}}
|
|
>
|
|
<header
|
|
ref={headRef}
|
|
className="nodedc-workspace-window__head"
|
|
tabIndex={maximized ? -1 : 0}
|
|
role="group"
|
|
aria-roledescription="Перемещаемая область окна"
|
|
aria-label={moveLabel}
|
|
aria-keyshortcuts="ArrowUp ArrowDown ArrowLeft ArrowRight"
|
|
onPointerDown={(event) => beginInteraction("move", event)}
|
|
onKeyDown={handleMoveKeyDown}
|
|
>
|
|
<div className="nodedc-workspace-window__titles">
|
|
<div className="nodedc-workspace-window__title-row">
|
|
<h2 id={titleId} className="nodedc-workspace-window__title">{title}</h2>
|
|
{status ? <span className="nodedc-workspace-window__status">{status}</span> : null}
|
|
</div>
|
|
{subtitle ? <p id={descriptionId} className="nodedc-workspace-window__subtitle">{subtitle}</p> : null}
|
|
</div>
|
|
<div className="nodedc-workspace-window__actions">
|
|
<button
|
|
type="button"
|
|
className="nodedc-workspace-window__action"
|
|
aria-label={maximized ? restoreLabel : maximizeLabel}
|
|
title={maximized ? restoreLabel : maximizeLabel}
|
|
onClick={() => onMaximizedChange(!maximized)}
|
|
>
|
|
<Icon name={maximized ? "minimize" : "expand"} size={15} strokeWidth={1.6} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="nodedc-workspace-window__action"
|
|
aria-label={closeLabel}
|
|
title={closeLabel}
|
|
onClick={onClose}
|
|
>
|
|
<Icon name="close" size={15} strokeWidth={1.6} />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
<div ref={bodyRef} className="nodedc-workspace-window__body">
|
|
{autoHeight ? <div ref={bodyContentRef} className="nodedc-workspace-window__body-content">{children}</div> : children}
|
|
</div>
|
|
{footer ? <footer ref={footerRef} className="nodedc-workspace-window__footer">{footer}</footer> : null}
|
|
{resizable && !maximized ? (
|
|
<button
|
|
type="button"
|
|
className="nodedc-workspace-window__resize"
|
|
aria-label={resizeLabel}
|
|
title={resizeLabel}
|
|
onPointerDown={(event) => beginInteraction("resize", event)}
|
|
onKeyDown={handleResizeKeyDown}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|