Canonicalize shared toolbar mechanics
This commit is contained in:
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user