Canonicalize shared toolbar mechanics

This commit is contained in:
DCCONSTRUCTIONS
2026-07-11 11:35:09 +03:00
parent 888fdbeb69
commit 6b42d913ea
10 changed files with 707 additions and 53 deletions
+282 -33
View File
@@ -1,8 +1,15 @@
import { useState, type CSSProperties } from "react";
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import {
calculateToolbarTargets,
NODEDC_TOOLBAR_DEFAULTS,
normalizeToolbarSettings,
stepToolbarSpring,
type ToolbarPlacement as CoreToolbarPlacement,
} from "@nodedc/ui-core";
import { cn } from "./cn.js";
import { Icon, type IconName } from "./Icon.js";
export type ToolbarPlacement = "left" | "right" | "bottom";
export type ToolbarPlacement = CoreToolbarPlacement;
export interface ToolbarItem<T extends string = string> {
id: T;
@@ -28,27 +35,241 @@ export interface ToolbarProps<T extends string = string> {
className?: string;
}
interface ToolbarMotion {
scale: number;
targetScale: number;
scaleVelocity: number;
shift: number;
targetShift: number;
shiftVelocity: number;
}
export function Toolbar<T extends string = string>({
items,
placement = "left",
background = "#111115",
border = "#111117",
outline = "#1c1c1c",
background = NODEDC_TOOLBAR_DEFAULTS.background,
border = NODEDC_TOOLBAR_DEFAULTS.border,
outline = NODEDC_TOOLBAR_DEFAULTS.outline,
accent = "#ff2f92",
minSize = 25,
maxSize = 87,
lensCount = 5,
minSize = NODEDC_TOOLBAR_DEFAULTS.minSize,
maxSize = NODEDC_TOOLBAR_DEFAULTS.maxSize,
lensCount = NODEDC_TOOLBAR_DEFAULTS.lensCount,
autoHide = false,
label = "Панель инструментов",
className,
}: ToolbarProps<T>) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [visible, setVisible] = useState(!autoHide);
const [dockActive, setDockActive] = useState(false);
const toolbarRef = useRef<HTMLDivElement | null>(null);
const buttonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const motionRef = useRef<Record<string, ToolbarMotion>>({});
const actionKeysRef = useRef<string[]>([]);
const frameRef = useRef<number | null>(null);
const frameLastTsRef = useRef(0);
const hideTimerRef = useRef<number | null>(null);
const hoverDockRef = useRef(false);
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 normalized = normalizeToolbarSettings({ minSize, maxSize, lensCount });
const safeMin = normalized.minSize;
const safeMax = normalized.maxSize;
const safeLens = normalized.lensCount;
const radius = Math.max(7, Math.round(safeMin * 0.28));
const glyphSize = Math.max(12, Math.min(18, Math.round(safeMin * 0.62)));
const settingsRef = useRef({ minSize: safeMin, maxSize: safeMax, lensCount: safeLens, vertical });
settingsRef.current = { minSize: safeMin, maxSize: safeMax, lensCount: safeLens, vertical };
actionKeysRef.current = items.map((item) => String(item.id));
const getMotion = useCallback((key: string) => {
let motion = motionRef.current[key];
if (!motion) {
motion = {
scale: 1,
targetScale: 1,
scaleVelocity: 0,
shift: 0,
targetShift: 0,
shiftVelocity: 0,
};
motionRef.current[key] = motion;
}
return motion;
}, []);
const renderFrame = useCallback((timestamp = performance.now()) => {
const keys = actionKeysRef.current;
if (!keys.length) {
frameRef.current = null;
return;
}
const dt = frameLastTsRef.current
? Math.min(0.032, Math.max(0.001, (timestamp - frameLastTsRef.current) / 1000))
: 0.016;
frameLastTsRef.current = timestamp;
let moving = false;
keys.forEach((key) => {
const button = buttonRefs.current[key];
if (!button) return;
const motion = getMotion(key);
[motion.scale, motion.scaleVelocity] = stepToolbarSpring(
motion.scale,
motion.scaleVelocity,
motion.targetScale,
dt,
);
[motion.shift, motion.shiftVelocity] = stepToolbarSpring(
motion.shift,
motion.shiftVelocity,
motion.targetShift,
dt,
);
if (
Math.abs(motion.scale - motion.targetScale) < 0.001
&& Math.abs(motion.scaleVelocity) < 0.001
&& Math.abs(motion.shift - motion.targetShift) < 0.05
&& Math.abs(motion.shiftVelocity) < 0.05
) {
motion.scale = motion.targetScale;
motion.scaleVelocity = 0;
motion.shift = motion.targetShift;
motion.shiftVelocity = 0;
} else {
moving = true;
}
button.style.setProperty("--nodedc-toolbar-scale", motion.scale.toFixed(4));
button.style.setProperty("--nodedc-toolbar-shift", `${motion.shift.toFixed(2)}px`);
});
if (moving) {
frameRef.current = window.requestAnimationFrame(renderFrame);
return;
}
frameRef.current = null;
frameLastTsRef.current = 0;
}, [getMotion]);
const startAnimation = useCallback(() => {
if (frameRef.current != null) return;
frameLastTsRef.current = 0;
frameRef.current = window.requestAnimationFrame(renderFrame);
}, [renderFrame]);
const setButtonTarget = useCallback((key: string, size: number, shift: number) => {
const motion = getMotion(key);
const { minSize: currentMin } = settingsRef.current;
motion.targetScale = currentMin > 0 ? size / currentMin : 1;
motion.targetShift = shift;
startAnimation();
}, [getMotion, startAnimation]);
const resetMagnification = useCallback(() => {
const { minSize: currentMin } = settingsRef.current;
actionKeysRef.current.forEach((key) => setButtonTarget(key, currentMin, 0));
setDockActive(false);
}, [setButtonTarget]);
const updateMagnification = useCallback((pointerCoordinate: number) => {
const toolbar = toolbarRef.current;
const keys = actionKeysRef.current;
if (!toolbar || !keys.length) return;
const { minSize: currentMin, maxSize: currentMax, lensCount: currentLens, vertical: isVertical } = settingsRef.current;
const toolbarRect = toolbar.getBoundingClientRect();
const entries = keys
.map((key) => {
const button = buttonRefs.current[key];
if (!button) return null;
const offset = isVertical ? button.offsetTop : button.offsetLeft;
const length = isVertical ? button.offsetHeight : button.offsetWidth;
const origin = isVertical ? toolbarRect.top : toolbarRect.left;
return { key, center: origin + offset + length / 2 };
})
.filter((entry): entry is { key: string; center: number } => Boolean(entry))
.sort((a, b) => a.center - b.center);
const targets = calculateToolbarTargets(
entries.map((entry) => entry.center),
pointerCoordinate,
{ minSize: currentMin, maxSize: currentMax, lensCount: currentLens, gap: NODEDC_TOOLBAR_DEFAULTS.gap },
);
entries.forEach(({ key }, index) => {
const target = targets[index];
if (target) setButtonTarget(key, target.size, target.shift);
});
}, [setButtonTarget]);
const showDock = useCallback(() => {
if (hideTimerRef.current != null) {
window.clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
setVisible(true);
}, []);
const scheduleHide = useCallback(() => {
if (!autoHide || hideTimerRef.current != null) return;
hideTimerRef.current = window.setTimeout(() => {
hideTimerRef.current = null;
if (hoverDockRef.current) return;
setVisible(false);
resetMagnification();
}, NODEDC_TOOLBAR_DEFAULTS.hideDelay);
}, [autoHide, resetMagnification]);
useEffect(() => {
if (!autoHide) {
setVisible(true);
return;
}
setVisible(false);
setDockActive(false);
}, [autoHide]);
useEffect(() => {
resetMagnification();
}, [placement, safeMin, safeMax, safeLens, resetMagnification]);
useEffect(() => {
if (!autoHide) return;
const handlePointerMove = (event: PointerEvent) => {
const threshold = Math.max(32, settingsRef.current.maxSize * 0.75);
const nearEdge = placement === "left"
? event.clientX <= threshold
: placement === "right"
? event.clientX >= window.innerWidth - threshold
: event.clientY >= window.innerHeight - threshold;
if (nearEdge) showDock();
else if (!hoverDockRef.current) scheduleHide();
};
window.addEventListener("pointermove", handlePointerMove, { passive: true });
return () => window.removeEventListener("pointermove", handlePointerMove);
}, [autoHide, placement, scheduleHide, showDock]);
useEffect(() => {
const handlePointerMove = (event: PointerEvent) => {
const toolbar = toolbarRef.current;
if (!hoverDockRef.current || !toolbar) return;
const rect = toolbar.getBoundingClientRect();
const inside = event.clientX >= rect.left - 2
&& event.clientX <= rect.right + 2
&& event.clientY >= rect.top - 2
&& event.clientY <= rect.bottom + 2;
if (inside) return;
hoverDockRef.current = false;
resetMagnification();
scheduleHide();
};
window.addEventListener("pointermove", handlePointerMove, { passive: true });
return () => window.removeEventListener("pointermove", handlePointerMove);
}, [resetMagnification, scheduleHide]);
useEffect(() => () => {
if (frameRef.current != null) window.cancelAnimationFrame(frameRef.current);
if (hideTimerRef.current != null) window.clearTimeout(hideTimerRef.current);
}, []);
const style = {
"--nodedc-toolbar-bg": background,
@@ -58,6 +279,8 @@ export function Toolbar<T extends string = string>({
"--nodedc-toolbar-min-size": `${safeMin}px`,
"--nodedc-toolbar-max-size": `${safeMax}px`,
"--nodedc-toolbar-radius": `${radius}px`,
"--nodedc-toolbar-gap": `${NODEDC_TOOLBAR_DEFAULTS.gap}px`,
"--nodedc-toolbar-padding": `${NODEDC_TOOLBAR_DEFAULTS.padding}px`,
} as CSSProperties;
return (
@@ -65,40 +288,66 @@ export function Toolbar<T extends string = string>({
className={cn("nodedc-toolbar-wrap", className)}
data-placement={placement}
data-visible={visible ? "true" : undefined}
data-active={dockActive ? "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;
{autoHide ? (
<div
className="nodedc-toolbar__hotzone"
aria-hidden="true"
onPointerEnter={showDock}
onPointerMove={showDock}
/>
) : null}
<div
ref={toolbarRef}
className="nodedc-toolbar"
role="toolbar"
aria-label={label}
onPointerEnter={() => {
hoverDockRef.current = true;
showDock();
setDockActive(true);
}}
onPointerMove={(event) => {
hoverDockRef.current = true;
showDock();
setDockActive(true);
updateMagnification(vertical ? event.clientY : event.clientX);
}}
onPointerLeave={() => {
hoverDockRef.current = false;
resetMagnification();
scheduleHide();
}}
>
{items.map((item) => {
const key = String(item.id);
return (
<button
key={item.id}
ref={(element) => { buttonRefs.current[key] = element; }}
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)}
onFocus={(event) => {
const button = event.currentTarget;
const toolbarRect = toolbarRef.current?.getBoundingClientRect();
if (!toolbarRect) return;
const coordinate = vertical
? toolbarRect.top + button.offsetTop + button.offsetHeight / 2
: toolbarRect.left + button.offsetLeft + button.offsetWidth / 2;
setDockActive(true);
updateMagnification(coordinate);
}}
onBlur={resetMagnification}
onClick={() => item.onSelect(item.id)}
>
<Icon name={item.icon} size={Math.max(12, Math.min(18, Math.round(safeMin * 0.62)))} />
<Icon name={item.icon} size={glyphSize} />
</button>
);
})}