Canonicalize shared toolbar mechanics
This commit is contained in:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user