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
+1 -1
View File
@@ -1,4 +1,4 @@
export * from "./accent.js";
export * from "./floating.js";
export * from "./theme.js";
export * from "./toolbar.js";
+106
View File
@@ -0,0 +1,106 @@
export type ToolbarPlacement = "left" | "right" | "bottom";
export interface ToolbarSettings {
minSize: number;
maxSize: number;
lensCount: number;
gap: number;
}
export interface ToolbarTarget {
size: number;
shift: number;
scale: number;
}
export const NODEDC_TOOLBAR_DEFAULTS = Object.freeze({
background: "#111115",
border: "#111117",
outline: "#1c1c1c",
minSize: 25,
maxSize: 87,
lensCount: 5,
gap: 10,
padding: 6,
hideDelay: 5000,
});
export const NODEDC_TOOLBAR_SPRING = Object.freeze({
mass: 0.1,
stiffness: 170,
damping: 12,
maxStep: 1 / 240,
});
const BASE_SCALE = 2.25;
const BASE_DISTANCE = 110;
const BASE_NUDGE = 40;
const NUDGE_TIGHTNESS = 0.82;
export function normalizeToolbarSettings({
minSize = NODEDC_TOOLBAR_DEFAULTS.minSize,
maxSize = NODEDC_TOOLBAR_DEFAULTS.maxSize,
lensCount = NODEDC_TOOLBAR_DEFAULTS.lensCount,
gap = NODEDC_TOOLBAR_DEFAULTS.gap,
}: Partial<ToolbarSettings> = {}): ToolbarSettings {
const safeMin = Math.max(18, Math.min(42, Number.isFinite(minSize) ? minSize : NODEDC_TOOLBAR_DEFAULTS.minSize));
const safeMax = Math.max(safeMin, Math.min(88, Number.isFinite(maxSize) ? maxSize : NODEDC_TOOLBAR_DEFAULTS.maxSize));
const roundedLens = Math.max(1, Math.min(13, Math.round(Number.isFinite(lensCount) ? lensCount : NODEDC_TOOLBAR_DEFAULTS.lensCount)));
return {
minSize: safeMin,
maxSize: safeMax,
lensCount: roundedLens % 2 === 0 ? Math.min(13, roundedLens + 1) : roundedLens,
gap: Math.max(0, Number.isFinite(gap) ? gap : NODEDC_TOOLBAR_DEFAULTS.gap),
};
}
export function stepToolbarSpring(value: number, velocity: number, target: number, dt: number) {
let nextValue = value;
let nextVelocity = velocity;
const duration = Math.min(0.032, Math.max(0.001, dt));
const steps = Math.max(1, Math.ceil(duration / NODEDC_TOOLBAR_SPRING.maxStep));
const step = duration / steps;
for (let index = 0; index < steps; index += 1) {
const displacement = nextValue - target;
const springForce = -NODEDC_TOOLBAR_SPRING.stiffness * displacement;
const dampingForce = -NODEDC_TOOLBAR_SPRING.damping * nextVelocity;
const acceleration = (springForce + dampingForce) / NODEDC_TOOLBAR_SPRING.mass;
nextVelocity += acceleration * step;
nextValue += nextVelocity * step;
}
return [nextValue, nextVelocity] as const;
}
export function calculateToolbarTargets(
centers: readonly number[],
pointerCoordinate: number,
settings: ToolbarSettings,
): ToolbarTarget[] {
const step = settings.minSize + settings.gap;
const lensRadius = Math.max(
settings.minSize,
Math.floor((settings.lensCount - 1) / 2) * step + settings.gap / 2,
);
const maxScale = settings.minSize > 0 ? settings.maxSize / settings.minSize : 1;
const scaledNudge = BASE_NUDGE
* (lensRadius / BASE_DISTANCE)
* (Math.max(0, maxScale - 1) / (BASE_SCALE - 1))
* NUDGE_TIGHTNESS;
return centers.map((center) => {
const distance = pointerCoordinate - center;
const absoluteDistance = Math.abs(distance);
const scale = absoluteDistance >= lensRadius
? 1
: 1 + (maxScale - 1) * (1 - absoluteDistance / lensRadius);
return {
scale,
size: settings.minSize * scale,
shift: absoluteDistance > lensRadius
? Math.sign(distance || 1) * -scaledNudge
: (-distance / lensRadius) * scaledNudge * scale,
};
});
}
+69 -14
View File
@@ -2104,20 +2104,39 @@ textarea.nodedc-field__control {
.nodedc-toolbar {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.42rem;
border: 1px solid var(--nodedc-toolbar-outline);
border-radius: calc(var(--nodedc-toolbar-radius) + 0.42rem);
background: color-mix(in srgb, var(--nodedc-toolbar-bg) 88%, transparent);
box-shadow: 0 10px 28px color-mix(in srgb, var(--nodedc-toolbar-outline) 36%, transparent);
backdrop-filter: blur(24px) saturate(1.12);
-webkit-backdrop-filter: blur(24px) saturate(1.12);
gap: var(--nodedc-toolbar-gap, 10px);
background: transparent;
transition: opacity 180ms ease, transform 220ms ease;
will-change: transform, opacity;
}
.nodedc-toolbar-wrap[data-placement="left"] .nodedc-toolbar,
.nodedc-toolbar-wrap[data-placement="right"] .nodedc-toolbar {
.nodedc-toolbar-wrap[data-placement="left"] .nodedc-toolbar {
width: var(--nodedc-toolbar-min-size);
flex-direction: column;
align-items: flex-start;
padding: var(--nodedc-toolbar-padding, 6px) 0;
}
.nodedc-toolbar-wrap[data-placement="right"] .nodedc-toolbar {
width: var(--nodedc-toolbar-min-size);
flex-direction: column;
align-items: flex-end;
padding: var(--nodedc-toolbar-padding, 6px) 0;
}
.nodedc-toolbar-wrap[data-placement="bottom"] .nodedc-toolbar {
height: var(--nodedc-toolbar-min-size);
align-items: flex-end;
padding: 0 var(--nodedc-toolbar-padding, 6px);
}
.nodedc-toolbar-wrap[data-placement="left"][data-active="true"] .nodedc-toolbar,
.nodedc-toolbar-wrap[data-placement="right"][data-active="true"] .nodedc-toolbar {
width: var(--nodedc-toolbar-max-size);
}
.nodedc-toolbar-wrap[data-placement="bottom"][data-active="true"] .nodedc-toolbar {
height: var(--nodedc-toolbar-max-size);
}
.nodedc-toolbar-wrap[data-autohide="true"]:not([data-visible="true"]) .nodedc-toolbar {
@@ -2125,6 +2144,10 @@ textarea.nodedc-field__control {
pointer-events: none;
}
.nodedc-toolbar-wrap[data-autohide="true"]:not([data-visible="true"]) {
pointer-events: none;
}
.nodedc-toolbar-wrap[data-placement="left"][data-autohide="true"]:not([data-visible="true"]) .nodedc-toolbar {
transform: translateX(calc(-100% - 1rem));
}
@@ -2139,6 +2162,7 @@ textarea.nodedc-field__control {
.nodedc-toolbar__hotzone {
position: fixed;
pointer-events: auto;
}
.nodedc-toolbar-wrap[data-placement="left"] .nodedc-toolbar__hotzone {
@@ -2160,9 +2184,9 @@ textarea.nodedc-field__control {
.nodedc-toolbar__button {
display: grid;
width: var(--nodedc-toolbar-item-size);
height: var(--nodedc-toolbar-item-size);
flex: 0 0 var(--nodedc-toolbar-item-size);
width: var(--nodedc-toolbar-min-size);
height: var(--nodedc-toolbar-min-size);
flex: 0 0 var(--nodedc-toolbar-min-size);
place-items: center;
border: 1px solid var(--nodedc-toolbar-border);
border-radius: var(--nodedc-toolbar-radius);
@@ -2170,7 +2194,32 @@ textarea.nodedc-field__control {
background: var(--nodedc-toolbar-bg);
color: var(--nodedc-toolbar-accent);
padding: 0;
transition: width 120ms ease, height 120ms ease, flex-basis 120ms ease, background 120ms ease, color 120ms ease;
line-height: 1;
cursor: pointer;
user-select: none;
will-change: transform;
transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease, color 120ms ease;
}
.nodedc-toolbar-wrap[data-placement="left"] .nodedc-toolbar__button {
transform: translateY(var(--nodedc-toolbar-shift, 0px)) scale(var(--nodedc-toolbar-scale, 1));
transform-origin: left center;
}
.nodedc-toolbar-wrap[data-placement="right"] .nodedc-toolbar__button {
transform: translateY(var(--nodedc-toolbar-shift, 0px)) scale(var(--nodedc-toolbar-scale, 1));
transform-origin: right center;
}
.nodedc-toolbar-wrap[data-placement="bottom"] .nodedc-toolbar__button {
transform: translateX(var(--nodedc-toolbar-shift, 0px)) scale(var(--nodedc-toolbar-scale, 1));
transform-origin: center bottom;
}
.nodedc-toolbar__button:hover,
.nodedc-toolbar__button:focus-visible {
border-color: var(--nodedc-toolbar-outline);
box-shadow: 0 10px 20px color-mix(in srgb, var(--nodedc-toolbar-outline) 30%, transparent);
}
.nodedc-toolbar__button[data-active="true"] {
@@ -2179,6 +2228,12 @@ textarea.nodedc-field__control {
color: rgb(var(--nodedc-on-accent-rgb));
}
.nodedc-toolbar__button:disabled {
cursor: default;
opacity: 0.45;
box-shadow: none;
}
.nodedc-window.nodedc-share-access-window {
width: min(35rem, calc(100vw - 2rem));
}
+1
View File
@@ -5,3 +5,4 @@ export * from "./mediaSource.js";
export * from "./modal.js";
export * from "./select.js";
export * from "./shareLink.js";
export * from "./toolbar.js";
+238
View File
@@ -0,0 +1,238 @@
import {
calculateToolbarTargets,
NODEDC_TOOLBAR_DEFAULTS,
normalizeToolbarSettings,
stepToolbarSpring,
type ToolbarPlacement,
} from "@nodedc/ui-core";
export interface ToolbarControllerOptions {
root: HTMLElement;
placement?: ToolbarPlacement;
background?: string;
border?: string;
outline?: string;
accent?: string;
minSize?: number;
maxSize?: number;
lensCount?: number;
autoHide?: boolean;
}
export interface ToolbarController {
refresh: () => void;
update: (options: Partial<Omit<ToolbarControllerOptions, "root">>) => void;
show: () => void;
hide: () => void;
reset: () => void;
destroy: () => void;
}
interface MotionState {
scale: number;
targetScale: number;
scaleVelocity: number;
shift: number;
targetShift: number;
shiftVelocity: number;
}
export function createToolbarController(initial: ToolbarControllerOptions): ToolbarController {
const root = initial.root;
const toolbar = root.querySelector<HTMLElement>(".nodedc-toolbar");
if (!toolbar) throw new Error("Toolbar root must contain .nodedc-toolbar");
let options = { ...initial };
let buttons: HTMLButtonElement[] = [];
let settings = normalizeToolbarSettings(options);
let visible = !options.autoHide;
let hovering = false;
let frame: number | null = null;
let lastTimestamp = 0;
let hideTimer: number | null = null;
const motion = new Map<HTMLButtonElement, MotionState>();
const getMotion = (button: HTMLButtonElement) => {
let state = motion.get(button);
if (!state) {
state = { scale: 1, targetScale: 1, scaleVelocity: 0, shift: 0, targetShift: 0, shiftVelocity: 0 };
motion.set(button, state);
}
return state;
};
const render = (timestamp = performance.now()) => {
const dt = lastTimestamp ? (timestamp - lastTimestamp) / 1000 : 0.016;
lastTimestamp = timestamp;
let moving = false;
buttons.forEach((button) => {
const state = getMotion(button);
[state.scale, state.scaleVelocity] = stepToolbarSpring(state.scale, state.scaleVelocity, state.targetScale, dt);
[state.shift, state.shiftVelocity] = stepToolbarSpring(state.shift, state.shiftVelocity, state.targetShift, dt);
if (
Math.abs(state.scale - state.targetScale) < 0.001
&& Math.abs(state.scaleVelocity) < 0.001
&& Math.abs(state.shift - state.targetShift) < 0.05
&& Math.abs(state.shiftVelocity) < 0.05
) {
state.scale = state.targetScale;
state.scaleVelocity = 0;
state.shift = state.targetShift;
state.shiftVelocity = 0;
} else {
moving = true;
}
button.style.setProperty("--nodedc-toolbar-scale", state.scale.toFixed(4));
button.style.setProperty("--nodedc-toolbar-shift", `${state.shift.toFixed(2)}px`);
});
if (moving) {
frame = window.requestAnimationFrame(render);
} else {
frame = null;
lastTimestamp = 0;
}
};
const start = () => {
if (frame != null) return;
lastTimestamp = 0;
frame = window.requestAnimationFrame(render);
};
const setTarget = (button: HTMLButtonElement, size: number, shift: number) => {
const state = getMotion(button);
state.targetScale = settings.minSize > 0 ? size / settings.minSize : 1;
state.targetShift = shift;
start();
};
const reset = () => {
buttons.forEach((button) => setTarget(button, settings.minSize, 0));
delete root.dataset.active;
};
const updateMagnification = (coordinate: number) => {
const vertical = options.placement !== "bottom";
const toolbarRect = toolbar.getBoundingClientRect();
const entries = buttons
.map((button) => ({
button,
center: (vertical ? toolbarRect.top + button.offsetTop + button.offsetHeight / 2 : toolbarRect.left + button.offsetLeft + button.offsetWidth / 2),
}))
.sort((a, b) => a.center - b.center);
const targets = calculateToolbarTargets(entries.map((entry) => entry.center), coordinate, settings);
entries.forEach(({ button }, index) => {
const target = targets[index];
if (target) setTarget(button, target.size, target.shift);
});
};
const show = () => {
if (hideTimer != null) {
window.clearTimeout(hideTimer);
hideTimer = null;
}
visible = true;
root.dataset.visible = "true";
};
const hide = () => {
if (!options.autoHide) return;
visible = false;
delete root.dataset.visible;
reset();
};
const scheduleHide = () => {
if (!options.autoHide || hideTimer != null) return;
hideTimer = window.setTimeout(() => {
hideTimer = null;
if (!hovering) hide();
}, NODEDC_TOOLBAR_DEFAULTS.hideDelay);
};
const applyOptions = () => {
settings = normalizeToolbarSettings(options);
const placement = options.placement ?? "left";
root.dataset.placement = placement;
root.dataset.autohide = options.autoHide ? "true" : "false";
root.style.setProperty("--nodedc-toolbar-bg", options.background ?? NODEDC_TOOLBAR_DEFAULTS.background);
root.style.setProperty("--nodedc-toolbar-border", options.border ?? NODEDC_TOOLBAR_DEFAULTS.border);
root.style.setProperty("--nodedc-toolbar-outline", options.outline ?? NODEDC_TOOLBAR_DEFAULTS.outline);
root.style.setProperty("--nodedc-toolbar-accent", options.accent ?? "#ff2f92");
root.style.setProperty("--nodedc-toolbar-min-size", `${settings.minSize}px`);
root.style.setProperty("--nodedc-toolbar-max-size", `${settings.maxSize}px`);
root.style.setProperty("--nodedc-toolbar-radius", `${Math.max(7, Math.round(settings.minSize * 0.28))}px`);
root.style.setProperty("--nodedc-toolbar-gap", `${settings.gap}px`);
root.style.setProperty("--nodedc-toolbar-padding", `${NODEDC_TOOLBAR_DEFAULTS.padding}px`);
if (options.autoHide) hide();
else {
visible = true;
root.dataset.visible = "true";
}
reset();
};
const refresh = () => {
buttons = Array.from(toolbar.querySelectorAll<HTMLButtonElement>(".nodedc-toolbar__button"));
const live = new Set(buttons);
Array.from(motion.keys()).forEach((button) => { if (!live.has(button)) motion.delete(button); });
buttons.forEach(getMotion);
reset();
};
const handleEnter = () => {
hovering = true;
show();
root.dataset.active = "true";
};
const handleMove = (event: PointerEvent) => {
hovering = true;
show();
root.dataset.active = "true";
updateMagnification(options.placement === "bottom" ? event.clientX : event.clientY);
};
const handleLeave = () => {
hovering = false;
reset();
scheduleHide();
};
const handleWindowMove = (event: PointerEvent) => {
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 (hovering && !inside) handleLeave();
if (!options.autoHide) return;
const threshold = Math.max(32, settings.maxSize * 0.75);
const nearEdge = options.placement === "right"
? event.clientX >= window.innerWidth - threshold
: options.placement === "bottom"
? event.clientY >= window.innerHeight - threshold
: event.clientX <= threshold;
if (nearEdge) show();
else if (visible && !hovering) scheduleHide();
};
toolbar.addEventListener("pointerenter", handleEnter);
toolbar.addEventListener("pointermove", handleMove);
toolbar.addEventListener("pointerleave", handleLeave);
window.addEventListener("pointermove", handleWindowMove, { passive: true });
refresh();
applyOptions();
return {
refresh,
update: (next) => { options = { ...options, ...next }; applyOptions(); },
show,
hide,
reset,
destroy: () => {
toolbar.removeEventListener("pointerenter", handleEnter);
toolbar.removeEventListener("pointermove", handleMove);
toolbar.removeEventListener("pointerleave", handleLeave);
window.removeEventListener("pointermove", handleWindowMove);
if (frame != null) window.cancelAnimationFrame(frame);
if (hideTimer != null) window.clearTimeout(hideTimer);
},
};
}
+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>
);
})}