358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
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 = CoreToolbarPlacement;
|
|
|
|
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;
|
|
}
|
|
|
|
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 = NODEDC_TOOLBAR_DEFAULTS.background,
|
|
border = NODEDC_TOOLBAR_DEFAULTS.border,
|
|
outline = NODEDC_TOOLBAR_DEFAULTS.outline,
|
|
accent = "#ff2f92",
|
|
minSize = NODEDC_TOOLBAR_DEFAULTS.minSize,
|
|
maxSize = NODEDC_TOOLBAR_DEFAULTS.maxSize,
|
|
lensCount = NODEDC_TOOLBAR_DEFAULTS.lensCount,
|
|
autoHide = false,
|
|
label = "Панель инструментов",
|
|
className,
|
|
}: ToolbarProps<T>) {
|
|
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 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,
|
|
"--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`,
|
|
"--nodedc-toolbar-gap": `${NODEDC_TOOLBAR_DEFAULTS.gap}px`,
|
|
"--nodedc-toolbar-padding": `${NODEDC_TOOLBAR_DEFAULTS.padding}px`,
|
|
} as CSSProperties;
|
|
|
|
return (
|
|
<div
|
|
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}
|
|
>
|
|
{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}
|
|
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={glyphSize} />
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|