feat(ui): add bounded workspace windows

This commit is contained in:
Codex
2026-07-16 20:56:23 +03:00
parent a1c4ecaa93
commit 5f583caa05
8 changed files with 661 additions and 3 deletions
+171 -2
View File
@@ -15,13 +15,13 @@
box-sizing: border-box;
}
:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close) {
:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize) {
border: 0;
outline: 0;
font: inherit;
}
:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close):focus-visible {
:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize):focus-visible {
background-color: var(--nodedc-focus-surface);
box-shadow: var(--nodedc-glass-control-shadow), inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
}
@@ -2163,6 +2163,174 @@ textarea.nodedc-field__control {
gap: var(--nodedc-space-2);
}
.nodedc-workspace-window {
position: absolute;
isolation: isolate;
display: grid;
min-width: 0;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
border: 0;
border-radius: var(--nodedc-radius-modal);
animation: nodedc-window-in var(--nodedc-duration-normal) var(--nodedc-ease-standard);
touch-action: none;
}
.nodedc-workspace-window[data-active="true"] {
box-shadow: var(--nodedc-modal-shadow), 0 0 0 1px color-mix(in srgb, var(--nodedc-text-primary) 16%, transparent);
}
.nodedc-workspace-window[data-maximized="true"] {
border-radius: inherit;
}
.nodedc-workspace-window__head {
display: grid;
min-height: 3.45rem;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--nodedc-space-3);
padding: 0.62rem 0.65rem 0.5rem 1rem;
cursor: grab;
outline: 0;
touch-action: none;
user-select: none;
}
.nodedc-workspace-window__head:focus-visible {
background: var(--nodedc-focus-surface);
box-shadow: inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
}
.nodedc-workspace-window[data-maximized="true"] .nodedc-workspace-window__head {
cursor: default;
}
.nodedc-workspace-window[data-interaction="move"] .nodedc-workspace-window__head {
cursor: grabbing;
}
.nodedc-workspace-window__titles {
display: grid;
min-width: 0;
gap: 0.2rem;
}
.nodedc-workspace-window__title-row {
display: flex;
min-width: 0;
align-items: center;
gap: var(--nodedc-space-2);
}
.nodedc-workspace-window__title,
.nodedc-workspace-window__subtitle {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nodedc-workspace-window__title {
color: var(--nodedc-text-primary);
font-size: var(--nodedc-font-size-md);
font-weight: var(--nodedc-font-weight-strong);
line-height: 1.2;
}
.nodedc-workspace-window__subtitle,
.nodedc-workspace-window__status {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
font-weight: var(--nodedc-font-weight-medium);
line-height: 1.25;
}
.nodedc-workspace-window__status {
flex: 0 0 auto;
}
.nodedc-workspace-window__actions {
display: flex;
align-items: center;
gap: 0.35rem;
}
.nodedc-workspace-window__action {
display: inline-grid;
width: 2rem;
height: 2rem;
place-items: center;
border-radius: var(--nodedc-radius-circle);
background: var(--nodedc-glass-control-bg);
color: var(--nodedc-text-secondary);
box-shadow: var(--nodedc-glass-control-shadow);
cursor: pointer;
}
.nodedc-workspace-window__action:hover {
background: var(--nodedc-glass-control-hover);
color: var(--nodedc-text-primary);
}
.nodedc-workspace-window__body {
min-width: 0;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
padding: 0.25rem 1rem 0.8rem;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.14) transparent;
touch-action: auto;
}
.nodedc-workspace-window__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--nodedc-space-2);
padding: 0.55rem 1rem 0.8rem;
}
.nodedc-workspace-window__resize {
position: absolute;
z-index: 3;
right: 0.2rem;
bottom: 0.2rem;
width: 1.55rem;
height: 1.55rem;
border-radius: 0.4rem;
background: transparent;
color: var(--nodedc-text-muted);
cursor: nwse-resize;
touch-action: none;
}
.nodedc-workspace-window__resize::before,
.nodedc-workspace-window__resize::after {
position: absolute;
right: 0.32rem;
bottom: 0.32rem;
border-right: 1px solid currentColor;
border-bottom: 1px solid currentColor;
content: "";
}
.nodedc-workspace-window__resize::before {
width: 0.72rem;
height: 0.72rem;
}
.nodedc-workspace-window__resize::after {
width: 0.36rem;
height: 0.36rem;
}
.nodedc-workspace-window[data-interaction="resize"] .nodedc-workspace-window__resize {
color: var(--nodedc-text-primary);
}
.nodedc-toolbar-wrap {
position: fixed;
z-index: calc(var(--nodedc-layer-header) + 20);
@@ -2823,6 +2991,7 @@ textarea.nodedc-field__control {
.nodedc-dropdown-surface,
.nodedc-overlay,
.nodedc-window,
.nodedc-workspace-window,
.nodedc-application-panel,
.nodedc-inspector__section-content {
animation: none;
+343
View File
@@ -0,0 +1,343 @@
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;
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;
};
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,
active = false,
zIndex,
closeLabel = "Закрыть окно",
maximizeLabel = "Развернуть окно",
restoreLabel = "Восстановить окно",
moveLabel = "Переместить окно",
resizeLabel = "Изменить размер окна",
className,
style,
onPointerDownCapture,
onFocusCapture,
...props
}: WorkspaceWindowProps) {
const titleId = useId();
const descriptionId = useId();
const interactionRef = useRef<WorkspaceInteraction | null>(null);
const rectRef = useRef(rect);
const onRectChangeRef = useRef(onRectChange);
const [interactionKind, setInteractionKind] = useState<WorkspaceInteraction["kind"] | null>(null);
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 = () => {
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(() => {
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);
emitRect(nextRect);
event.preventDefault();
};
const endInteraction = (event: globalThis.PointerEvent) => {
if (interactionRef.current?.pointerId !== event.pointerId) return;
interactionRef.current = null;
setInteractionKind(null);
};
window.addEventListener("pointermove", handlePointerMove, { passive: false });
window.addEventListener("pointerup", endInteraction);
window.addEventListener("pointercancel", endInteraction);
return () => {
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 (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);
emitRect(startRect);
interactionRef.current = {
kind,
pointerId: event.pointerId,
startClientX: event.clientX,
startClientY: event.clientY,
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
{...props}
className={cn("nodedc-workspace-window nodedc-glass-material nodedc-material-rim nodedc-ui-root", className)}
data-active={active ? "true" : undefined}
data-maximized={maximized ? "true" : undefined}
data-resizable={resizable ? "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
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 className="nodedc-workspace-window__body">{children}</div>
{footer ? <footer 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>
);
}
+1
View File
@@ -20,3 +20,4 @@ export * from "./Settings.js";
export * from "./SharingModals.js";
export * from "./Toolbar.js";
export * from "./Window.js";
export * from "./WorkspaceWindow.js";