Canonicalize shared toolbar mechanics
This commit is contained in:
parent
888fdbeb69
commit
6b42d913ea
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 от повторного запуска. До завершения операции закрытие можно заблокировать.
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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)}
|
>
|
||||||
|
{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={() => {
|
onPointerLeave={() => {
|
||||||
setHoveredIndex(null);
|
hoverDockRef.current = false;
|
||||||
if (autoHide) setVisible(false);
|
resetMagnification();
|
||||||
|
scheduleHide();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{autoHide ? <div className="nodedc-toolbar__hotzone" aria-hidden="true" onPointerEnter={() => setVisible(true)} /> : null}
|
{items.map((item) => {
|
||||||
<div className="nodedc-toolbar" role="toolbar" aria-label={label}>
|
const key = String(item.id);
|
||||||
{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;
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue