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

View File

@ -367,8 +367,8 @@ textarea {
} }
[data-nodedc-theme="light"] .catalog-icon-catalog .catalog-icon-tile { [data-nodedc-theme="light"] .catalog-icon-catalog .catalog-icon-tile {
background: #f0f0f2; background: color-mix(in srgb, var(--nodedc-panel-item-bg) 50%, white);
box-shadow: inset 0 0 0 1px #e4e4e7; box-shadow: none;
} }
.catalog-header-theme-switch { .catalog-header-theme-switch {

View File

@ -27,7 +27,7 @@ Core не зависит от React.
`@nodedc/ui-react` предоставляет компоненты для Launcher/Hub, Engine, Ops и 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 ### DOM

View File

@ -85,6 +85,8 @@ Window — единая механика открытия modal и правой
Дефолты перенесены из Engine без переизобретения: `#111115`, `#111117`, `#1c1c1c`, `25 px`, `87 px`, `5`. Toolbar получает те же section actions, что и основная navigation, но не владеет route-state. Дефолты перенесены из 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
ConfirmationModal оборачивает Window и защищает async-confirm от повторного запуска. До завершения операции закрытие можно заблокировать. ConfirmationModal оборачивает Window и защищает async-confirm от повторного запуска. До завершения операции закрытие можно заблокировать.

View File

@ -1,4 +1,4 @@
export * from "./accent.js"; export * from "./accent.js";
export * from "./floating.js"; export * from "./floating.js";
export * from "./theme.js"; export * from "./theme.js";
export * from "./toolbar.js";

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,
};
});
}

View File

@ -2104,20 +2104,39 @@ textarea.nodedc-field__control {
.nodedc-toolbar { .nodedc-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.35rem; gap: var(--nodedc-toolbar-gap, 10px);
padding: 0.42rem; background: transparent;
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);
transition: opacity 180ms ease, transform 220ms ease; transition: opacity 180ms ease, transform 220ms ease;
will-change: transform, opacity;
} }
.nodedc-toolbar-wrap[data-placement="left"] .nodedc-toolbar, .nodedc-toolbar-wrap[data-placement="left"] .nodedc-toolbar {
.nodedc-toolbar-wrap[data-placement="right"] .nodedc-toolbar { width: var(--nodedc-toolbar-min-size);
flex-direction: column; 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 { .nodedc-toolbar-wrap[data-autohide="true"]:not([data-visible="true"]) .nodedc-toolbar {
@ -2125,6 +2144,10 @@ textarea.nodedc-field__control {
pointer-events: none; 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 { .nodedc-toolbar-wrap[data-placement="left"][data-autohide="true"]:not([data-visible="true"]) .nodedc-toolbar {
transform: translateX(calc(-100% - 1rem)); transform: translateX(calc(-100% - 1rem));
} }
@ -2139,6 +2162,7 @@ textarea.nodedc-field__control {
.nodedc-toolbar__hotzone { .nodedc-toolbar__hotzone {
position: fixed; position: fixed;
pointer-events: auto;
} }
.nodedc-toolbar-wrap[data-placement="left"] .nodedc-toolbar__hotzone { .nodedc-toolbar-wrap[data-placement="left"] .nodedc-toolbar__hotzone {
@ -2160,9 +2184,9 @@ textarea.nodedc-field__control {
.nodedc-toolbar__button { .nodedc-toolbar__button {
display: grid; display: grid;
width: var(--nodedc-toolbar-item-size); width: var(--nodedc-toolbar-min-size);
height: var(--nodedc-toolbar-item-size); height: var(--nodedc-toolbar-min-size);
flex: 0 0 var(--nodedc-toolbar-item-size); flex: 0 0 var(--nodedc-toolbar-min-size);
place-items: center; place-items: center;
border: 1px solid var(--nodedc-toolbar-border); border: 1px solid var(--nodedc-toolbar-border);
border-radius: var(--nodedc-toolbar-radius); border-radius: var(--nodedc-toolbar-radius);
@ -2170,7 +2194,32 @@ textarea.nodedc-field__control {
background: var(--nodedc-toolbar-bg); background: var(--nodedc-toolbar-bg);
color: var(--nodedc-toolbar-accent); color: var(--nodedc-toolbar-accent);
padding: 0; 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"] { .nodedc-toolbar__button[data-active="true"] {
@ -2179,6 +2228,12 @@ textarea.nodedc-field__control {
color: rgb(var(--nodedc-on-accent-rgb)); 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 { .nodedc-window.nodedc-share-access-window {
width: min(35rem, calc(100vw - 2rem)); width: min(35rem, calc(100vw - 2rem));
} }

View File

@ -5,3 +5,4 @@ export * from "./mediaSource.js";
export * from "./modal.js"; export * from "./modal.js";
export * from "./select.js"; export * from "./select.js";
export * from "./shareLink.js"; export * from "./shareLink.js";
export * from "./toolbar.js";

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);
},
};
}

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 { cn } from "./cn.js";
import { Icon, type IconName } from "./Icon.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> { export interface ToolbarItem<T extends string = string> {
id: T; id: T;
@ -28,27 +35,241 @@ export interface ToolbarProps<T extends string = string> {
className?: string; className?: string;
} }
interface ToolbarMotion {
scale: number;
targetScale: number;
scaleVelocity: number;
shift: number;
targetShift: number;
shiftVelocity: number;
}
export function Toolbar<T extends string = string>({ export function Toolbar<T extends string = string>({
items, items,
placement = "left", placement = "left",
background = "#111115", background = NODEDC_TOOLBAR_DEFAULTS.background,
border = "#111117", border = NODEDC_TOOLBAR_DEFAULTS.border,
outline = "#1c1c1c", outline = NODEDC_TOOLBAR_DEFAULTS.outline,
accent = "#ff2f92", accent = "#ff2f92",
minSize = 25, minSize = NODEDC_TOOLBAR_DEFAULTS.minSize,
maxSize = 87, maxSize = NODEDC_TOOLBAR_DEFAULTS.maxSize,
lensCount = 5, lensCount = NODEDC_TOOLBAR_DEFAULTS.lensCount,
autoHide = false, autoHide = false,
label = "Панель инструментов", label = "Панель инструментов",
className, className,
}: ToolbarProps<T>) { }: ToolbarProps<T>) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [visible, setVisible] = useState(!autoHide); 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 vertical = placement !== "bottom";
const safeMin = Math.max(18, Math.min(42, minSize)); const normalized = normalizeToolbarSettings({ minSize, maxSize, lensCount });
const safeMax = Math.max(safeMin, Math.min(88, maxSize)); const safeMin = normalized.minSize;
const safeLens = Math.max(1, Math.min(13, lensCount % 2 === 0 ? lensCount + 1 : lensCount)); const safeMax = normalized.maxSize;
const radius = Math.max(9, Math.round(safeMin * 0.42)); 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 = { const style = {
"--nodedc-toolbar-bg": background, "--nodedc-toolbar-bg": background,
@ -58,6 +279,8 @@ export function Toolbar<T extends string = string>({
"--nodedc-toolbar-min-size": `${safeMin}px`, "--nodedc-toolbar-min-size": `${safeMin}px`,
"--nodedc-toolbar-max-size": `${safeMax}px`, "--nodedc-toolbar-max-size": `${safeMax}px`,
"--nodedc-toolbar-radius": `${radius}px`, "--nodedc-toolbar-radius": `${radius}px`,
"--nodedc-toolbar-gap": `${NODEDC_TOOLBAR_DEFAULTS.gap}px`,
"--nodedc-toolbar-padding": `${NODEDC_TOOLBAR_DEFAULTS.padding}px`,
} as CSSProperties; } as CSSProperties;
return ( return (
@ -65,40 +288,66 @@ export function Toolbar<T extends string = string>({
className={cn("nodedc-toolbar-wrap", className)} className={cn("nodedc-toolbar-wrap", className)}
data-placement={placement} data-placement={placement}
data-visible={visible ? "true" : undefined} data-visible={visible ? "true" : undefined}
data-active={dockActive ? "true" : undefined}
data-autohide={autoHide ? "true" : undefined} data-autohide={autoHide ? "true" : undefined}
style={style} 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} {autoHide ? (
<div className="nodedc-toolbar" role="toolbar" aria-label={label}> <div
{items.map((item, index) => { className="nodedc-toolbar__hotzone"
const distance = hoveredIndex === null ? Number.POSITIVE_INFINITY : Math.abs(index - hoveredIndex); aria-hidden="true"
const lensRadius = Math.floor(safeLens / 2); onPointerEnter={showDock}
const influence = distance > lensRadius ? 0 : 1 - distance / Math.max(1, lensRadius + 1); onPointerMove={showDock}
const size = safeMin + (safeMax - safeMin) * influence; />
) : 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 ( return (
<button <button
key={item.id} key={item.id}
ref={(element) => { buttonRefs.current[key] = element; }}
type="button" type="button"
className="nodedc-toolbar__button" className="nodedc-toolbar__button"
data-active={item.active ? "true" : undefined} data-active={item.active ? "true" : undefined}
aria-label={item.label} aria-label={item.label}
aria-pressed={item.active} aria-pressed={item.active}
disabled={item.disabled} disabled={item.disabled}
style={{ onFocus={(event) => {
"--nodedc-toolbar-item-size": `${Math.round(size)}px`, const button = event.currentTarget;
"--nodedc-toolbar-item-shift": `${Math.round((size - safeMin) / (vertical ? 2 : 2))}px`, const toolbarRect = toolbarRef.current?.getBoundingClientRect();
} as CSSProperties} if (!toolbarRect) return;
onPointerEnter={() => setHoveredIndex(index)} const coordinate = vertical
onFocus={() => setHoveredIndex(index)} ? toolbarRect.top + button.offsetTop + button.offsetHeight / 2
onBlur={() => setHoveredIndex(null)} : toolbarRect.left + button.offsetLeft + button.offsetWidth / 2;
setDockActive(true);
updateMagnification(coordinate);
}}
onBlur={resetMagnification}
onClick={() => item.onSelect(item.id)} 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> </button>
); );
})} })}

View File

@ -212,14 +212,17 @@
"status": "baseline", "status": "baseline",
"package": "@nodedc/ui-react", "package": "@nodedc/ui-react",
"exports": ["Toolbar"], "exports": ["Toolbar"],
"domPackage": "@nodedc/ui-dom",
"domExports": ["createToolbarController"],
"domContract": ["nodedc-toolbar-wrap", "nodedc-toolbar", "nodedc-toolbar__button"], "domContract": ["nodedc-toolbar-wrap", "nodedc-toolbar", "nodedc-toolbar__button"],
"summary": "Engine/BIM-derived magnifying application toolbar with left, right and bottom placements.", "summary": "Engine/BIM-derived magnifying application toolbar with left, right and bottom placements.",
"variants": ["left", "right", "bottom"], "variants": ["left", "right", "bottom"],
"behavior": ["controlled active item", "lens magnification", "optional autohide hotzone", "theme-independent geometry"], "behavior": ["controlled active item", "spring lens magnification without flex reflow", "axis-aware neighbor shift", "optional delayed autohide hotzone", "theme-independent geometry"],
"rules": [ "rules": [
"Engine defaults are background #111115, border #111117, outline #1c1c1c, min 25 px, max 87 px and five lens items.", "Engine defaults are background #111115, border #111117, outline #1c1c1c, min 25 px, max 87 px and five lens items.",
"Toolbar actions call application navigation; they do not duplicate route state.", "Toolbar actions call application navigation; they do not duplicate route state.",
"Placement and material are controlled values and belong to the persisted application layout." "Placement and material are controlled values and belong to the persisted application layout.",
"The shared component owns pointer tracking, spring animation, lens geometry and autohide timers; applications must not reimplement them."
] ]
}, },
{ {