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 {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 от повторного запуска. До завершения операции закрытие можно заблокировать.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export * from "./accent.js";
|
||||
export * from "./floating.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 {
|
||||
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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,3 +5,4 @@ export * from "./mediaSource.js";
|
|||
export * from "./modal.js";
|
||||
export * from "./select.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 { Icon, type IconName } from "./Icon.js";
|
||||
|
||||
export type ToolbarPlacement = "left" | "right" | "bottom";
|
||||
export type ToolbarPlacement = CoreToolbarPlacement;
|
||||
|
||||
export interface ToolbarItem<T extends string = string> {
|
||||
id: T;
|
||||
|
|
@ -28,27 +35,241 @@ export interface ToolbarProps<T extends string = string> {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
interface ToolbarMotion {
|
||||
scale: number;
|
||||
targetScale: number;
|
||||
scaleVelocity: number;
|
||||
shift: number;
|
||||
targetShift: number;
|
||||
shiftVelocity: number;
|
||||
}
|
||||
|
||||
export function Toolbar<T extends string = string>({
|
||||
items,
|
||||
placement = "left",
|
||||
background = "#111115",
|
||||
border = "#111117",
|
||||
outline = "#1c1c1c",
|
||||
background = NODEDC_TOOLBAR_DEFAULTS.background,
|
||||
border = NODEDC_TOOLBAR_DEFAULTS.border,
|
||||
outline = NODEDC_TOOLBAR_DEFAULTS.outline,
|
||||
accent = "#ff2f92",
|
||||
minSize = 25,
|
||||
maxSize = 87,
|
||||
lensCount = 5,
|
||||
minSize = NODEDC_TOOLBAR_DEFAULTS.minSize,
|
||||
maxSize = NODEDC_TOOLBAR_DEFAULTS.maxSize,
|
||||
lensCount = NODEDC_TOOLBAR_DEFAULTS.lensCount,
|
||||
autoHide = false,
|
||||
label = "Панель инструментов",
|
||||
className,
|
||||
}: ToolbarProps<T>) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const [visible, setVisible] = useState(!autoHide);
|
||||
const [dockActive, setDockActive] = useState(false);
|
||||
const toolbarRef = useRef<HTMLDivElement | null>(null);
|
||||
const buttonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const motionRef = useRef<Record<string, ToolbarMotion>>({});
|
||||
const actionKeysRef = useRef<string[]>([]);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const frameLastTsRef = useRef(0);
|
||||
const hideTimerRef = useRef<number | null>(null);
|
||||
const hoverDockRef = useRef(false);
|
||||
|
||||
const vertical = placement !== "bottom";
|
||||
const safeMin = Math.max(18, Math.min(42, minSize));
|
||||
const safeMax = Math.max(safeMin, Math.min(88, maxSize));
|
||||
const safeLens = Math.max(1, Math.min(13, lensCount % 2 === 0 ? lensCount + 1 : lensCount));
|
||||
const radius = Math.max(9, Math.round(safeMin * 0.42));
|
||||
const normalized = normalizeToolbarSettings({ minSize, maxSize, lensCount });
|
||||
const safeMin = normalized.minSize;
|
||||
const safeMax = normalized.maxSize;
|
||||
const safeLens = normalized.lensCount;
|
||||
const radius = Math.max(7, Math.round(safeMin * 0.28));
|
||||
const glyphSize = Math.max(12, Math.min(18, Math.round(safeMin * 0.62)));
|
||||
const settingsRef = useRef({ minSize: safeMin, maxSize: safeMax, lensCount: safeLens, vertical });
|
||||
settingsRef.current = { minSize: safeMin, maxSize: safeMax, lensCount: safeLens, vertical };
|
||||
actionKeysRef.current = items.map((item) => String(item.id));
|
||||
|
||||
const getMotion = useCallback((key: string) => {
|
||||
let motion = motionRef.current[key];
|
||||
if (!motion) {
|
||||
motion = {
|
||||
scale: 1,
|
||||
targetScale: 1,
|
||||
scaleVelocity: 0,
|
||||
shift: 0,
|
||||
targetShift: 0,
|
||||
shiftVelocity: 0,
|
||||
};
|
||||
motionRef.current[key] = motion;
|
||||
}
|
||||
return motion;
|
||||
}, []);
|
||||
|
||||
const renderFrame = useCallback((timestamp = performance.now()) => {
|
||||
const keys = actionKeysRef.current;
|
||||
if (!keys.length) {
|
||||
frameRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const dt = frameLastTsRef.current
|
||||
? Math.min(0.032, Math.max(0.001, (timestamp - frameLastTsRef.current) / 1000))
|
||||
: 0.016;
|
||||
frameLastTsRef.current = timestamp;
|
||||
let moving = false;
|
||||
|
||||
keys.forEach((key) => {
|
||||
const button = buttonRefs.current[key];
|
||||
if (!button) return;
|
||||
const motion = getMotion(key);
|
||||
[motion.scale, motion.scaleVelocity] = stepToolbarSpring(
|
||||
motion.scale,
|
||||
motion.scaleVelocity,
|
||||
motion.targetScale,
|
||||
dt,
|
||||
);
|
||||
[motion.shift, motion.shiftVelocity] = stepToolbarSpring(
|
||||
motion.shift,
|
||||
motion.shiftVelocity,
|
||||
motion.targetShift,
|
||||
dt,
|
||||
);
|
||||
|
||||
if (
|
||||
Math.abs(motion.scale - motion.targetScale) < 0.001
|
||||
&& Math.abs(motion.scaleVelocity) < 0.001
|
||||
&& Math.abs(motion.shift - motion.targetShift) < 0.05
|
||||
&& Math.abs(motion.shiftVelocity) < 0.05
|
||||
) {
|
||||
motion.scale = motion.targetScale;
|
||||
motion.scaleVelocity = 0;
|
||||
motion.shift = motion.targetShift;
|
||||
motion.shiftVelocity = 0;
|
||||
} else {
|
||||
moving = true;
|
||||
}
|
||||
|
||||
button.style.setProperty("--nodedc-toolbar-scale", motion.scale.toFixed(4));
|
||||
button.style.setProperty("--nodedc-toolbar-shift", `${motion.shift.toFixed(2)}px`);
|
||||
});
|
||||
|
||||
if (moving) {
|
||||
frameRef.current = window.requestAnimationFrame(renderFrame);
|
||||
return;
|
||||
}
|
||||
frameRef.current = null;
|
||||
frameLastTsRef.current = 0;
|
||||
}, [getMotion]);
|
||||
|
||||
const startAnimation = useCallback(() => {
|
||||
if (frameRef.current != null) return;
|
||||
frameLastTsRef.current = 0;
|
||||
frameRef.current = window.requestAnimationFrame(renderFrame);
|
||||
}, [renderFrame]);
|
||||
|
||||
const setButtonTarget = useCallback((key: string, size: number, shift: number) => {
|
||||
const motion = getMotion(key);
|
||||
const { minSize: currentMin } = settingsRef.current;
|
||||
motion.targetScale = currentMin > 0 ? size / currentMin : 1;
|
||||
motion.targetShift = shift;
|
||||
startAnimation();
|
||||
}, [getMotion, startAnimation]);
|
||||
|
||||
const resetMagnification = useCallback(() => {
|
||||
const { minSize: currentMin } = settingsRef.current;
|
||||
actionKeysRef.current.forEach((key) => setButtonTarget(key, currentMin, 0));
|
||||
setDockActive(false);
|
||||
}, [setButtonTarget]);
|
||||
|
||||
const updateMagnification = useCallback((pointerCoordinate: number) => {
|
||||
const toolbar = toolbarRef.current;
|
||||
const keys = actionKeysRef.current;
|
||||
if (!toolbar || !keys.length) return;
|
||||
const { minSize: currentMin, maxSize: currentMax, lensCount: currentLens, vertical: isVertical } = settingsRef.current;
|
||||
const toolbarRect = toolbar.getBoundingClientRect();
|
||||
const entries = keys
|
||||
.map((key) => {
|
||||
const button = buttonRefs.current[key];
|
||||
if (!button) return null;
|
||||
const offset = isVertical ? button.offsetTop : button.offsetLeft;
|
||||
const length = isVertical ? button.offsetHeight : button.offsetWidth;
|
||||
const origin = isVertical ? toolbarRect.top : toolbarRect.left;
|
||||
return { key, center: origin + offset + length / 2 };
|
||||
})
|
||||
.filter((entry): entry is { key: string; center: number } => Boolean(entry))
|
||||
.sort((a, b) => a.center - b.center);
|
||||
|
||||
const targets = calculateToolbarTargets(
|
||||
entries.map((entry) => entry.center),
|
||||
pointerCoordinate,
|
||||
{ minSize: currentMin, maxSize: currentMax, lensCount: currentLens, gap: NODEDC_TOOLBAR_DEFAULTS.gap },
|
||||
);
|
||||
entries.forEach(({ key }, index) => {
|
||||
const target = targets[index];
|
||||
if (target) setButtonTarget(key, target.size, target.shift);
|
||||
});
|
||||
}, [setButtonTarget]);
|
||||
|
||||
const showDock = useCallback(() => {
|
||||
if (hideTimerRef.current != null) {
|
||||
window.clearTimeout(hideTimerRef.current);
|
||||
hideTimerRef.current = null;
|
||||
}
|
||||
setVisible(true);
|
||||
}, []);
|
||||
|
||||
const scheduleHide = useCallback(() => {
|
||||
if (!autoHide || hideTimerRef.current != null) return;
|
||||
hideTimerRef.current = window.setTimeout(() => {
|
||||
hideTimerRef.current = null;
|
||||
if (hoverDockRef.current) return;
|
||||
setVisible(false);
|
||||
resetMagnification();
|
||||
}, NODEDC_TOOLBAR_DEFAULTS.hideDelay);
|
||||
}, [autoHide, resetMagnification]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoHide) {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
setVisible(false);
|
||||
setDockActive(false);
|
||||
}, [autoHide]);
|
||||
|
||||
useEffect(() => {
|
||||
resetMagnification();
|
||||
}, [placement, safeMin, safeMax, safeLens, resetMagnification]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoHide) return;
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const threshold = Math.max(32, settingsRef.current.maxSize * 0.75);
|
||||
const nearEdge = placement === "left"
|
||||
? event.clientX <= threshold
|
||||
: placement === "right"
|
||||
? event.clientX >= window.innerWidth - threshold
|
||||
: event.clientY >= window.innerHeight - threshold;
|
||||
if (nearEdge) showDock();
|
||||
else if (!hoverDockRef.current) scheduleHide();
|
||||
};
|
||||
window.addEventListener("pointermove", handlePointerMove, { passive: true });
|
||||
return () => window.removeEventListener("pointermove", handlePointerMove);
|
||||
}, [autoHide, placement, scheduleHide, showDock]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const toolbar = toolbarRef.current;
|
||||
if (!hoverDockRef.current || !toolbar) return;
|
||||
const rect = toolbar.getBoundingClientRect();
|
||||
const inside = event.clientX >= rect.left - 2
|
||||
&& event.clientX <= rect.right + 2
|
||||
&& event.clientY >= rect.top - 2
|
||||
&& event.clientY <= rect.bottom + 2;
|
||||
if (inside) return;
|
||||
hoverDockRef.current = false;
|
||||
resetMagnification();
|
||||
scheduleHide();
|
||||
};
|
||||
window.addEventListener("pointermove", handlePointerMove, { passive: true });
|
||||
return () => window.removeEventListener("pointermove", handlePointerMove);
|
||||
}, [resetMagnification, scheduleHide]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (frameRef.current != null) window.cancelAnimationFrame(frameRef.current);
|
||||
if (hideTimerRef.current != null) window.clearTimeout(hideTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const style = {
|
||||
"--nodedc-toolbar-bg": background,
|
||||
|
|
@ -58,6 +279,8 @@ export function Toolbar<T extends string = string>({
|
|||
"--nodedc-toolbar-min-size": `${safeMin}px`,
|
||||
"--nodedc-toolbar-max-size": `${safeMax}px`,
|
||||
"--nodedc-toolbar-radius": `${radius}px`,
|
||||
"--nodedc-toolbar-gap": `${NODEDC_TOOLBAR_DEFAULTS.gap}px`,
|
||||
"--nodedc-toolbar-padding": `${NODEDC_TOOLBAR_DEFAULTS.padding}px`,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
|
|
@ -65,40 +288,66 @@ export function Toolbar<T extends string = string>({
|
|||
className={cn("nodedc-toolbar-wrap", className)}
|
||||
data-placement={placement}
|
||||
data-visible={visible ? "true" : undefined}
|
||||
data-active={dockActive ? "true" : undefined}
|
||||
data-autohide={autoHide ? "true" : undefined}
|
||||
style={style}
|
||||
onPointerEnter={() => setVisible(true)}
|
||||
onPointerLeave={() => {
|
||||
setHoveredIndex(null);
|
||||
if (autoHide) setVisible(false);
|
||||
}}
|
||||
>
|
||||
{autoHide ? <div className="nodedc-toolbar__hotzone" aria-hidden="true" onPointerEnter={() => setVisible(true)} /> : null}
|
||||
<div className="nodedc-toolbar" role="toolbar" aria-label={label}>
|
||||
{items.map((item, index) => {
|
||||
const distance = hoveredIndex === null ? Number.POSITIVE_INFINITY : Math.abs(index - hoveredIndex);
|
||||
const lensRadius = Math.floor(safeLens / 2);
|
||||
const influence = distance > lensRadius ? 0 : 1 - distance / Math.max(1, lensRadius + 1);
|
||||
const size = safeMin + (safeMax - safeMin) * influence;
|
||||
{autoHide ? (
|
||||
<div
|
||||
className="nodedc-toolbar__hotzone"
|
||||
aria-hidden="true"
|
||||
onPointerEnter={showDock}
|
||||
onPointerMove={showDock}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
className="nodedc-toolbar"
|
||||
role="toolbar"
|
||||
aria-label={label}
|
||||
onPointerEnter={() => {
|
||||
hoverDockRef.current = true;
|
||||
showDock();
|
||||
setDockActive(true);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
hoverDockRef.current = true;
|
||||
showDock();
|
||||
setDockActive(true);
|
||||
updateMagnification(vertical ? event.clientY : event.clientX);
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
hoverDockRef.current = false;
|
||||
resetMagnification();
|
||||
scheduleHide();
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const key = String(item.id);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
ref={(element) => { buttonRefs.current[key] = element; }}
|
||||
type="button"
|
||||
className="nodedc-toolbar__button"
|
||||
data-active={item.active ? "true" : undefined}
|
||||
aria-label={item.label}
|
||||
aria-pressed={item.active}
|
||||
disabled={item.disabled}
|
||||
style={{
|
||||
"--nodedc-toolbar-item-size": `${Math.round(size)}px`,
|
||||
"--nodedc-toolbar-item-shift": `${Math.round((size - safeMin) / (vertical ? 2 : 2))}px`,
|
||||
} as CSSProperties}
|
||||
onPointerEnter={() => setHoveredIndex(index)}
|
||||
onFocus={() => setHoveredIndex(index)}
|
||||
onBlur={() => setHoveredIndex(null)}
|
||||
onFocus={(event) => {
|
||||
const button = event.currentTarget;
|
||||
const toolbarRect = toolbarRef.current?.getBoundingClientRect();
|
||||
if (!toolbarRect) return;
|
||||
const coordinate = vertical
|
||||
? toolbarRect.top + button.offsetTop + button.offsetHeight / 2
|
||||
: toolbarRect.left + button.offsetLeft + button.offsetWidth / 2;
|
||||
setDockActive(true);
|
||||
updateMagnification(coordinate);
|
||||
}}
|
||||
onBlur={resetMagnification}
|
||||
onClick={() => item.onSelect(item.id)}
|
||||
>
|
||||
<Icon name={item.icon} size={Math.max(12, Math.min(18, Math.round(safeMin * 0.62)))} />
|
||||
<Icon name={item.icon} size={glyphSize} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -212,14 +212,17 @@
|
|||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["Toolbar"],
|
||||
"domPackage": "@nodedc/ui-dom",
|
||||
"domExports": ["createToolbarController"],
|
||||
"domContract": ["nodedc-toolbar-wrap", "nodedc-toolbar", "nodedc-toolbar__button"],
|
||||
"summary": "Engine/BIM-derived magnifying application toolbar with left, right and bottom placements.",
|
||||
"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": [
|
||||
"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.",
|
||||
"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