diff --git a/apps/catalog/src/styles.css b/apps/catalog/src/styles.css index e429ba2..975be16 100644 --- a/apps/catalog/src/styles.css +++ b/apps/catalog/src/styles.css @@ -367,8 +367,8 @@ textarea { } [data-nodedc-theme="light"] .catalog-icon-catalog .catalog-icon-tile { - background: #f0f0f2; - box-shadow: inset 0 0 0 1px #e4e4e7; + background: color-mix(in srgb, var(--nodedc-panel-item-bg) 50%, white); + box-shadow: none; } .catalog-header-theme-switch { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 380cd83..e1e92c3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,7 +27,7 @@ Core не зависит от React. `@nodedc/ui-react` предоставляет компоненты для Launcher/Hub, Engine, Ops и React-модулей. Компоненты контролируемые: бизнес-состояние остаётся у приложения, а визуальное и базовое интерактивное поведение принадлежит библиотеке. -`ApplicationShell` и `ApplicationPanel` образуют готовый launcher-style шаблон нового React-приложения. `useApplicationWorkspace` задаёт единую механику открытия navigation/content/expanded состояний. `MediaSourceField` отделяет стабильный UI выбора файла/URL от upload/storage API приложения. `Toolbar` фиксирует Engine/BIM dock, magnification, placement и autohide. `Icon` предоставляет ограниченный аудированный словарь glyph, чтобы приложения не импортировали vendor-наборы независимо. +`ApplicationShell` и `ApplicationPanel` образуют готовый launcher-style шаблон нового React-приложения. `useApplicationWorkspace` задаёт единую механику открытия navigation/content/expanded состояний. `MediaSourceField` отделяет стабильный UI выбора файла/URL от upload/storage API приложения. `Toolbar` фиксирует Engine/BIM dock, magnification, placement и autohide; его физика находится в Core и используется React-компонентом и DOM-controller без расхождения формул. `Icon` предоставляет ограниченный аудированный словарь glyph, чтобы приложения не импортировали vendor-наборы независимо. ### DOM diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 4fd663f..7ed9c42 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -85,6 +85,8 @@ Window — единая механика открытия modal и правой Дефолты перенесены из Engine без переизобретения: `#111115`, `#111117`, `#1c1c1c`, `25 px`, `87 px`, `5`. Toolbar получает те же section actions, что и основная navigation, но не владеет route-state. +Компонент сам владеет всей механикой dock: base-размеры кнопок не меняют flex-layout, lens использует `transform: scale + shift`, движение сглаживается единым spring-циклом `requestAnimationFrame`, а autohide закрывает панель с задержкой и возвращает её через edge hotzone. Consumer не реализует hover-расчёты, таймеры или анимацию повторно. React использует `Toolbar`, а BIM/CMS и другие DOM-приложения подключают тот же контракт через `createToolbarController`; обе обёртки используют общие `normalizeToolbarSettings`, `calculateToolbarTargets` и `stepToolbarSpring` из `@nodedc/ui-core`. + ## ConfirmationModal ConfirmationModal оборачивает Window и защищает async-confirm от повторного запуска. До завершения операции закрытие можно заблокировать. diff --git a/packages/ui-core/src/index.ts b/packages/ui-core/src/index.ts index 5c46ad5..33b6ece 100644 --- a/packages/ui-core/src/index.ts +++ b/packages/ui-core/src/index.ts @@ -1,4 +1,4 @@ export * from "./accent.js"; export * from "./floating.js"; export * from "./theme.js"; - +export * from "./toolbar.js"; diff --git a/packages/ui-core/src/toolbar.ts b/packages/ui-core/src/toolbar.ts new file mode 100644 index 0000000..9ff1d50 --- /dev/null +++ b/packages/ui-core/src/toolbar.ts @@ -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 { + 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, + }; + }); +} diff --git a/packages/ui-core/styles.css b/packages/ui-core/styles.css index 7f32af4..4c81cbc 100644 --- a/packages/ui-core/styles.css +++ b/packages/ui-core/styles.css @@ -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)); } diff --git a/packages/ui-dom/src/index.ts b/packages/ui-dom/src/index.ts index 952ed55..3fecfcb 100644 --- a/packages/ui-dom/src/index.ts +++ b/packages/ui-dom/src/index.ts @@ -5,3 +5,4 @@ export * from "./mediaSource.js"; export * from "./modal.js"; export * from "./select.js"; export * from "./shareLink.js"; +export * from "./toolbar.js"; diff --git a/packages/ui-dom/src/toolbar.ts b/packages/ui-dom/src/toolbar.ts new file mode 100644 index 0000000..bb89c3a --- /dev/null +++ b/packages/ui-dom/src/toolbar.ts @@ -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>) => 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(".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(); + + 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(".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); + }, + }; +} diff --git a/packages/ui-react/src/Toolbar.tsx b/packages/ui-react/src/Toolbar.tsx index d91df8e..ee86557 100644 --- a/packages/ui-react/src/Toolbar.tsx +++ b/packages/ui-react/src/Toolbar.tsx @@ -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 { id: T; @@ -28,27 +35,241 @@ export interface ToolbarProps { className?: string; } +interface ToolbarMotion { + scale: number; + targetScale: number; + scaleVelocity: number; + shift: number; + targetShift: number; + shiftVelocity: number; +} + export function Toolbar({ 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) { - const [hoveredIndex, setHoveredIndex] = useState(null); const [visible, setVisible] = useState(!autoHide); + const [dockActive, setDockActive] = useState(false); + const toolbarRef = useRef(null); + const buttonRefs = useRef>({}); + const motionRef = useRef>({}); + const actionKeysRef = useRef([]); + const frameRef = useRef(null); + const frameLastTsRef = useRef(0); + const hideTimerRef = useRef(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({ "--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({ 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 ?