Canonicalize shared toolbar mechanics

This commit is contained in:
DCCONSTRUCTIONS
2026-07-11 11:35:09 +03:00
parent 888fdbeb69
commit 6b42d913ea
10 changed files with 707 additions and 53 deletions
+1
View File
@@ -5,3 +5,4 @@ export * from "./mediaSource.js";
export * from "./modal.js";
export * from "./select.js";
export * from "./shareLink.js";
export * from "./toolbar.js";
+238
View File
@@ -0,0 +1,238 @@
import {
calculateToolbarTargets,
NODEDC_TOOLBAR_DEFAULTS,
normalizeToolbarSettings,
stepToolbarSpring,
type ToolbarPlacement,
} from "@nodedc/ui-core";
export interface ToolbarControllerOptions {
root: HTMLElement;
placement?: ToolbarPlacement;
background?: string;
border?: string;
outline?: string;
accent?: string;
minSize?: number;
maxSize?: number;
lensCount?: number;
autoHide?: boolean;
}
export interface ToolbarController {
refresh: () => void;
update: (options: Partial<Omit<ToolbarControllerOptions, "root">>) => void;
show: () => void;
hide: () => void;
reset: () => void;
destroy: () => void;
}
interface MotionState {
scale: number;
targetScale: number;
scaleVelocity: number;
shift: number;
targetShift: number;
shiftVelocity: number;
}
export function createToolbarController(initial: ToolbarControllerOptions): ToolbarController {
const root = initial.root;
const toolbar = root.querySelector<HTMLElement>(".nodedc-toolbar");
if (!toolbar) throw new Error("Toolbar root must contain .nodedc-toolbar");
let options = { ...initial };
let buttons: HTMLButtonElement[] = [];
let settings = normalizeToolbarSettings(options);
let visible = !options.autoHide;
let hovering = false;
let frame: number | null = null;
let lastTimestamp = 0;
let hideTimer: number | null = null;
const motion = new Map<HTMLButtonElement, MotionState>();
const getMotion = (button: HTMLButtonElement) => {
let state = motion.get(button);
if (!state) {
state = { scale: 1, targetScale: 1, scaleVelocity: 0, shift: 0, targetShift: 0, shiftVelocity: 0 };
motion.set(button, state);
}
return state;
};
const render = (timestamp = performance.now()) => {
const dt = lastTimestamp ? (timestamp - lastTimestamp) / 1000 : 0.016;
lastTimestamp = timestamp;
let moving = false;
buttons.forEach((button) => {
const state = getMotion(button);
[state.scale, state.scaleVelocity] = stepToolbarSpring(state.scale, state.scaleVelocity, state.targetScale, dt);
[state.shift, state.shiftVelocity] = stepToolbarSpring(state.shift, state.shiftVelocity, state.targetShift, dt);
if (
Math.abs(state.scale - state.targetScale) < 0.001
&& Math.abs(state.scaleVelocity) < 0.001
&& Math.abs(state.shift - state.targetShift) < 0.05
&& Math.abs(state.shiftVelocity) < 0.05
) {
state.scale = state.targetScale;
state.scaleVelocity = 0;
state.shift = state.targetShift;
state.shiftVelocity = 0;
} else {
moving = true;
}
button.style.setProperty("--nodedc-toolbar-scale", state.scale.toFixed(4));
button.style.setProperty("--nodedc-toolbar-shift", `${state.shift.toFixed(2)}px`);
});
if (moving) {
frame = window.requestAnimationFrame(render);
} else {
frame = null;
lastTimestamp = 0;
}
};
const start = () => {
if (frame != null) return;
lastTimestamp = 0;
frame = window.requestAnimationFrame(render);
};
const setTarget = (button: HTMLButtonElement, size: number, shift: number) => {
const state = getMotion(button);
state.targetScale = settings.minSize > 0 ? size / settings.minSize : 1;
state.targetShift = shift;
start();
};
const reset = () => {
buttons.forEach((button) => setTarget(button, settings.minSize, 0));
delete root.dataset.active;
};
const updateMagnification = (coordinate: number) => {
const vertical = options.placement !== "bottom";
const toolbarRect = toolbar.getBoundingClientRect();
const entries = buttons
.map((button) => ({
button,
center: (vertical ? toolbarRect.top + button.offsetTop + button.offsetHeight / 2 : toolbarRect.left + button.offsetLeft + button.offsetWidth / 2),
}))
.sort((a, b) => a.center - b.center);
const targets = calculateToolbarTargets(entries.map((entry) => entry.center), coordinate, settings);
entries.forEach(({ button }, index) => {
const target = targets[index];
if (target) setTarget(button, target.size, target.shift);
});
};
const show = () => {
if (hideTimer != null) {
window.clearTimeout(hideTimer);
hideTimer = null;
}
visible = true;
root.dataset.visible = "true";
};
const hide = () => {
if (!options.autoHide) return;
visible = false;
delete root.dataset.visible;
reset();
};
const scheduleHide = () => {
if (!options.autoHide || hideTimer != null) return;
hideTimer = window.setTimeout(() => {
hideTimer = null;
if (!hovering) hide();
}, NODEDC_TOOLBAR_DEFAULTS.hideDelay);
};
const applyOptions = () => {
settings = normalizeToolbarSettings(options);
const placement = options.placement ?? "left";
root.dataset.placement = placement;
root.dataset.autohide = options.autoHide ? "true" : "false";
root.style.setProperty("--nodedc-toolbar-bg", options.background ?? NODEDC_TOOLBAR_DEFAULTS.background);
root.style.setProperty("--nodedc-toolbar-border", options.border ?? NODEDC_TOOLBAR_DEFAULTS.border);
root.style.setProperty("--nodedc-toolbar-outline", options.outline ?? NODEDC_TOOLBAR_DEFAULTS.outline);
root.style.setProperty("--nodedc-toolbar-accent", options.accent ?? "#ff2f92");
root.style.setProperty("--nodedc-toolbar-min-size", `${settings.minSize}px`);
root.style.setProperty("--nodedc-toolbar-max-size", `${settings.maxSize}px`);
root.style.setProperty("--nodedc-toolbar-radius", `${Math.max(7, Math.round(settings.minSize * 0.28))}px`);
root.style.setProperty("--nodedc-toolbar-gap", `${settings.gap}px`);
root.style.setProperty("--nodedc-toolbar-padding", `${NODEDC_TOOLBAR_DEFAULTS.padding}px`);
if (options.autoHide) hide();
else {
visible = true;
root.dataset.visible = "true";
}
reset();
};
const refresh = () => {
buttons = Array.from(toolbar.querySelectorAll<HTMLButtonElement>(".nodedc-toolbar__button"));
const live = new Set(buttons);
Array.from(motion.keys()).forEach((button) => { if (!live.has(button)) motion.delete(button); });
buttons.forEach(getMotion);
reset();
};
const handleEnter = () => {
hovering = true;
show();
root.dataset.active = "true";
};
const handleMove = (event: PointerEvent) => {
hovering = true;
show();
root.dataset.active = "true";
updateMagnification(options.placement === "bottom" ? event.clientX : event.clientY);
};
const handleLeave = () => {
hovering = false;
reset();
scheduleHide();
};
const handleWindowMove = (event: PointerEvent) => {
const rect = toolbar.getBoundingClientRect();
const inside = event.clientX >= rect.left - 2 && event.clientX <= rect.right + 2
&& event.clientY >= rect.top - 2 && event.clientY <= rect.bottom + 2;
if (hovering && !inside) handleLeave();
if (!options.autoHide) return;
const threshold = Math.max(32, settings.maxSize * 0.75);
const nearEdge = options.placement === "right"
? event.clientX >= window.innerWidth - threshold
: options.placement === "bottom"
? event.clientY >= window.innerHeight - threshold
: event.clientX <= threshold;
if (nearEdge) show();
else if (visible && !hovering) scheduleHide();
};
toolbar.addEventListener("pointerenter", handleEnter);
toolbar.addEventListener("pointermove", handleMove);
toolbar.addEventListener("pointerleave", handleLeave);
window.addEventListener("pointermove", handleWindowMove, { passive: true });
refresh();
applyOptions();
return {
refresh,
update: (next) => { options = { ...options, ...next }; applyOptions(); },
show,
hide,
reset,
destroy: () => {
toolbar.removeEventListener("pointerenter", handleEnter);
toolbar.removeEventListener("pointermove", handleMove);
toolbar.removeEventListener("pointerleave", handleLeave);
window.removeEventListener("pointermove", handleWindowMove);
if (frame != null) window.cancelAnimationFrame(frame);
if (hideTimer != null) window.clearTimeout(hideTimer);
},
};
}