Persist catalog layout and add inspector toolbar
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user