Persist catalog layout and add inspector toolbar

This commit is contained in:
DCCONSTRUCTIONS
2026-07-10 23:28:10 +03:00
parent 8ca12f2676
commit 406e692a5e
14 changed files with 715 additions and 268 deletions
@@ -49,6 +49,8 @@ export interface ApplicationPanelProps extends Omit<HTMLAttributes<HTMLElement>,
expandLabel?: string;
closeLabel?: string;
onExpandedChange?: (expanded: boolean) => void;
/** Persistent custom controls rendered before utility icon actions. */
headerTools?: ReactNode;
/** Persistent utility actions rendered before expand/close in the panel header. */
utilityActions?: ApplicationPanelUtilityAction[];
onClose: () => void;
@@ -70,6 +72,7 @@ export function ApplicationPanel({
expandLabel = "Развернуть окно",
closeLabel = "Закрыть окно",
onExpandedChange,
headerTools,
utilityActions = [],
onClose,
children,
@@ -85,6 +88,7 @@ export function ApplicationPanel({
{description ? <p>{description}</p> : null}
</div>
<div className="nodedc-application-panel__actions">
{headerTools ? <div className="nodedc-application-panel__tools">{headerTools}</div> : null}
{utilityActions.map((action) => (
<button
key={action.label}
+108
View File
@@ -0,0 +1,108 @@
import { useState, type CSSProperties } from "react";
import { cn } from "./cn.js";
import { Icon, type IconName } from "./Icon.js";
export type ToolbarPlacement = "left" | "right" | "bottom";
export interface ToolbarItem<T extends string = string> {
id: T;
label: string;
icon: IconName;
active?: boolean;
disabled?: boolean;
onSelect: (id: T) => void;
}
export interface ToolbarProps<T extends string = string> {
items: Array<ToolbarItem<T>>;
placement?: ToolbarPlacement;
background?: string;
border?: string;
outline?: string;
accent?: string;
minSize?: number;
maxSize?: number;
lensCount?: number;
autoHide?: boolean;
label?: string;
className?: string;
}
export function Toolbar<T extends string = string>({
items,
placement = "left",
background = "#111115",
border = "#111117",
outline = "#1c1c1c",
accent = "#ff2f92",
minSize = 25,
maxSize = 87,
lensCount = 5,
autoHide = false,
label = "Панель инструментов",
className,
}: ToolbarProps<T>) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [visible, setVisible] = useState(!autoHide);
const vertical = placement !== "bottom";
const safeMin = Math.max(18, Math.min(42, minSize));
const safeMax = Math.max(safeMin, Math.min(88, maxSize));
const safeLens = Math.max(1, Math.min(13, lensCount % 2 === 0 ? lensCount + 1 : lensCount));
const radius = Math.max(9, Math.round(safeMin * 0.42));
const style = {
"--nodedc-toolbar-bg": background,
"--nodedc-toolbar-border": border,
"--nodedc-toolbar-outline": outline,
"--nodedc-toolbar-accent": accent,
"--nodedc-toolbar-min-size": `${safeMin}px`,
"--nodedc-toolbar-max-size": `${safeMax}px`,
"--nodedc-toolbar-radius": `${radius}px`,
} as CSSProperties;
return (
<div
className={cn("nodedc-toolbar-wrap", className)}
data-placement={placement}
data-visible={visible ? "true" : undefined}
data-autohide={autoHide ? "true" : undefined}
style={style}
onPointerEnter={() => setVisible(true)}
onPointerLeave={() => {
setHoveredIndex(null);
if (autoHide) setVisible(false);
}}
>
{autoHide ? <div className="nodedc-toolbar__hotzone" aria-hidden="true" onPointerEnter={() => setVisible(true)} /> : null}
<div className="nodedc-toolbar" role="toolbar" aria-label={label}>
{items.map((item, index) => {
const distance = hoveredIndex === null ? Number.POSITIVE_INFINITY : Math.abs(index - hoveredIndex);
const lensRadius = Math.floor(safeLens / 2);
const influence = distance > lensRadius ? 0 : 1 - distance / Math.max(1, lensRadius + 1);
const size = safeMin + (safeMax - safeMin) * influence;
return (
<button
key={item.id}
type="button"
className="nodedc-toolbar__button"
data-active={item.active ? "true" : undefined}
aria-label={item.label}
aria-pressed={item.active}
disabled={item.disabled}
style={{
"--nodedc-toolbar-item-size": `${Math.round(size)}px`,
"--nodedc-toolbar-item-shift": `${Math.round((size - safeMin) / (vertical ? 2 : 2))}px`,
} as CSSProperties}
onPointerEnter={() => setHoveredIndex(index)}
onFocus={() => setHoveredIndex(index)}
onBlur={() => setHoveredIndex(null)}
onClick={() => item.onSelect(item.id)}
>
<Icon name={item.icon} size={Math.max(12, Math.min(18, Math.round(safeMin * 0.62)))} />
</button>
);
})}
</div>
</div>
);
}
+48 -2
View File
@@ -2,12 +2,14 @@ import {
useEffect,
useId,
useRef,
useState,
type HTMLAttributes,
type PointerEvent,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { cn } from "./cn.js";
import { Icon } from "./Icon.js";
const focusableSelector = [
"a[href]",
@@ -34,6 +36,7 @@ export interface WindowProps extends Omit<HTMLAttributes<HTMLDivElement>, "title
closeOnEscape?: boolean;
lockBodyScroll?: boolean;
trapFocus?: boolean;
draggable?: boolean;
onClose: () => void;
}
@@ -50,13 +53,17 @@ export function Window({
closeOnEscape = true,
lockBodyScroll,
trapFocus,
draggable = false,
onClose,
className,
style,
...props
}: WindowProps) {
const titleId = useId();
const descriptionId = useId();
const dialogRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<{ pointerId: number; offsetX: number; offsetY: number } | null>(null);
const [dragPosition, setDragPosition] = useState<{ left: number; top: number } | null>(null);
const shouldLockBodyScroll = lockBodyScroll ?? placement === "center";
const shouldTrapFocus = trapFocus ?? placement === "center";
@@ -104,6 +111,34 @@ export function Window({
};
}, [closeOnEscape, onClose, open, shouldLockBodyScroll, shouldTrapFocus]);
useEffect(() => {
if (!open) setDragPosition(null);
}, [open]);
useEffect(() => {
if (!draggable || !open) return;
const handlePointerMove = (event: globalThis.PointerEvent) => {
const drag = dragRef.current;
const dialog = dialogRef.current;
if (!drag || !dialog || event.pointerId !== drag.pointerId) return;
const bounds = dialog.getBoundingClientRect();
const left = Math.max(8, Math.min(window.innerWidth - bounds.width - 8, event.clientX - drag.offsetX));
const top = Math.max(76, Math.min(window.innerHeight - bounds.height - 8, event.clientY - drag.offsetY));
setDragPosition({ left, top });
};
const handlePointerUp = (event: globalThis.PointerEvent) => {
if (dragRef.current?.pointerId === event.pointerId) dragRef.current = null;
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
return () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
};
}, [draggable, open]);
if (!open || typeof document === "undefined") return null;
const allowBackdropClose = closeOnBackdrop ?? placement === "center";
@@ -119,20 +154,31 @@ export function Window({
className={cn("nodedc-window nodedc-material-rim", className)}
data-size={size === "md" ? undefined : size}
data-placement={placement}
data-draggable={draggable ? "true" : undefined}
role="dialog"
aria-modal={placement === "center" ? "true" : undefined}
aria-labelledby={titleId}
aria-describedby={subtitle ? descriptionId : undefined}
tabIndex={-1}
style={dragPosition ? { ...style, position: "fixed", left: dragPosition.left, top: dragPosition.top, right: "auto", bottom: "auto" } : style}
{...props}
>
<header className="nodedc-window__head">
<header
className="nodedc-window__head"
onPointerDown={(event) => {
if (!draggable || event.button !== 0 || (event.target as HTMLElement).closest("button, input, select, textarea, a")) return;
const bounds = dialogRef.current?.getBoundingClientRect();
if (!bounds) return;
dragRef.current = { pointerId: event.pointerId, offsetX: event.clientX - bounds.left, offsetY: event.clientY - bounds.top };
event.preventDefault();
}}
>
<div className="nodedc-window__titles">
<h2 id={titleId} className="nodedc-window__title">{title}</h2>
{subtitle ? <p id={descriptionId} className="nodedc-window__subtitle">{subtitle}</p> : null}
</div>
<button type="button" className="nodedc-window__close" aria-label={closeLabel} onClick={onClose}>
<span className="nodedc-close-mark" aria-hidden="true" />
<Icon name="close" size={16} strokeWidth={1.6} />
</button>
</header>
<div className="nodedc-window__body">{children}</div>
+1
View File
@@ -17,4 +17,5 @@ export * from "./Select.js";
export * from "./StatusBadge.js";
export * from "./Settings.js";
export * from "./SharingModals.js";
export * from "./Toolbar.js";
export * from "./Window.js";